From 0c5118a1a7a6feb3403011e599dc7068c40c6d8b Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Thu, 2 May 2024 17:34:18 -0300 Subject: [PATCH 1/2] chore: improve test coverage --- jest.config.json | 14 ++-- .../src/auth-jwt.module-definition.spec.ts | 25 ++++++ .../src/auth-jwt.module-definition.ts | 12 +-- .../decorators/auth-public.decorator.spec.ts | 4 +- .../src/guards/auth.guard.spec.ts | 27 +++++++ .../nestjs-authentication/src/index.spec.ts | 35 ++++++++ packages/nestjs-common/package.json | 4 + .../decorators/auth-user.decorator.spec.ts | 63 +++++++++++++++ packages/nestjs-common/src/index.spec.ts | 31 ++++++++ .../utils/create-settings-provider.spec.ts | 76 ++++++++++++++++++ packages/nestjs-core/src/index.spec.ts | 13 +++ .../src/modules/defer-external.spec.ts | 79 +++++++++++++++++++ .../src/modules/negotiate-controller.spec.ts | 46 +++++++++++ .../constants/error-codes.constants.spec.ts | 28 +++++++ .../src/utils/map-http-status.util.spec.ts | 22 ++++++ packages/nestjs-federated/src/index.spec.ts | 58 ++++++++++++++ .../entity-not-found.exception.spec.ts | 25 ++++++ .../otp-type-not-defined.exception.spec.ts | 25 ++++++ packages/nestjs-otp/src/index.spec.ts | 29 +++++++ packages/nestjs-otp/src/otp.module.spec.ts | 33 +++++++- packages/typeorm-common/package.json | 3 +- .../src/proxies/entity-manager.proxy.spec.ts | 52 ++++++++++++ 22 files changed, 685 insertions(+), 19 deletions(-) create mode 100644 packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts create mode 100644 packages/nestjs-authentication/src/index.spec.ts create mode 100644 packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts create mode 100644 packages/nestjs-common/src/index.spec.ts create mode 100644 packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts create mode 100644 packages/nestjs-core/src/index.spec.ts create mode 100644 packages/nestjs-core/src/modules/defer-external.spec.ts create mode 100644 packages/nestjs-core/src/modules/negotiate-controller.spec.ts create mode 100644 packages/nestjs-exception/src/constants/error-codes.constants.spec.ts create mode 100644 packages/nestjs-exception/src/utils/map-http-status.util.spec.ts create mode 100644 packages/nestjs-federated/src/index.spec.ts create mode 100644 packages/nestjs-otp/src/exceptions/entity-not-found.exception.spec.ts create mode 100644 packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts create mode 100644 packages/nestjs-otp/src/index.spec.ts create mode 100644 packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts diff --git a/jest.config.json b/jest.config.json index bb5ee9560..6ff394659 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,9 +1,5 @@ { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], + "moduleFileExtensions": ["js", "json", "ts"], "globals": { "ts-jest": { "tsconfig": "tsconfig.jest.json" @@ -18,10 +14,7 @@ } }, "testRegex": ".*\\.spec\\.ts$", - "testPathIgnorePatterns": [ - "/node_modules/", - "/dist/" - ], + "testPathIgnorePatterns": ["/node_modules/", "/dist/"], "transform": { "^.+\\.ts$": "ts-jest" }, @@ -30,6 +23,9 @@ "!packages/**/*.d.ts", "!packages/**/*.interface.ts", "!packages/**/*.e2e-spec.ts", + "!packages/**/*.factory.ts", + "!packages/**/*.seeder.ts", + "!packages/**/*.seeding.ts", "!packages/nestjs-samples/src/**/main.ts", "!**/node_modules/**", "!**/__mocks__/**", diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts b/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts new file mode 100644 index 000000000..f077e334f --- /dev/null +++ b/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts @@ -0,0 +1,25 @@ +import { FactoryProvider } from '@nestjs/common'; +import { + AuthJwtOptions, + createAuthJwtAppGuardProvider, +} from './auth-jwt.module-definition'; +import { AuthJwtGuard } from './auth-jwt.guard'; +import { mock } from 'jest-mock-extended'; + +describe(createAuthJwtAppGuardProvider.name, () => { + const guard = mock(); + + it('should return null if appGuard is explicitly false', async () => { + const options: Pick = { appGuard: false }; + const provider = createAuthJwtAppGuardProvider(options) as FactoryProvider; + const result = await provider.useFactory(options, guard); + expect(result).toBeNull(); + }); + + it('should return appGuard if set, or fall back to default', async () => { + const options = { appGuard: guard }; + const provider = createAuthJwtAppGuardProvider(options) as FactoryProvider; + const result = await provider.useFactory(options, guard); + expect(result).toBe(options.appGuard); + }); +}); diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts b/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts index 8de1756ba..3c0834e03 100644 --- a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts +++ b/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts @@ -107,13 +107,13 @@ export function createAuthJwtOptionsProvider( } export function createAuthJwtVerifyTokenServiceProvider( - optionsOverrides?: AuthJwtOptions, + optionsOverrides?: Pick, ): Provider { return { provide: AUTH_JWT_MODULE_VERIFY_TOKEN_SERVICE_TOKEN, inject: [RAW_OPTIONS_TOKEN, VerifyTokenService], useFactory: async ( - options: AuthJwtOptionsInterface, + options: Pick, defaultService: VerifyTokenServiceInterface, ) => optionsOverrides?.verifyTokenService ?? @@ -123,24 +123,24 @@ export function createAuthJwtVerifyTokenServiceProvider( } export function createAuthJwtUserLookupServiceProvider( - optionsOverrides?: AuthJwtOptions, + optionsOverrides?: Pick, ): Provider { return { provide: AUTH_JWT_MODULE_USER_LOOKUP_SERVICE_TOKEN, inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: AuthJwtOptionsInterface) => + useFactory: async (options: Pick) => optionsOverrides?.userLookupService ?? options.userLookupService, }; } export function createAuthJwtAppGuardProvider( - optionsOverrides?: AuthJwtOptions, + optionsOverrides?: Pick, ): Provider { return { provide: APP_GUARD, inject: [RAW_OPTIONS_TOKEN, AuthJwtGuard], useFactory: async ( - options: AuthJwtOptionsInterface, + options: Pick, defaultGuard: AuthJwtGuard, ) => { // get app guard from the options diff --git a/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts b/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts index 187202fac..1c93d4abc 100644 --- a/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts +++ b/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts @@ -1,4 +1,4 @@ -import * as common from '@nestjs/common'; +import { SetMetadata } from '@nestjs/common'; import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../authentication.constants'; import { AuthPublic } from './auth-public.decorator'; @@ -12,7 +12,7 @@ describe(AuthPublic.name, () => { it('should set metadata to disable guards', () => { AuthPublic(); // Assert that SetMetadata was called with specific arguments - expect(common.SetMetadata).toHaveBeenCalledWith( + expect(SetMetadata).toHaveBeenCalledWith( AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, true, ); diff --git a/packages/nestjs-authentication/src/guards/auth.guard.spec.ts b/packages/nestjs-authentication/src/guards/auth.guard.spec.ts index fef85606d..54bfe8e8e 100644 --- a/packages/nestjs-authentication/src/guards/auth.guard.spec.ts +++ b/packages/nestjs-authentication/src/guards/auth.guard.spec.ts @@ -47,4 +47,31 @@ describe(AuthGuard.name, () => { const guardInstance = new Guard(mockSettings, reflector); expect(guardInstance.canActivate(mockContext)).toBeTruthy(); }); + + it('should respect enable guard and disabled from reflector callback', () => { + const mockContext = { + getHandler: jest.fn(), + getClass: jest.fn(), + } as unknown as ExecutionContext; + + const mockSettings = { enableGuards: true }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); + const Guard = AuthGuard('local', { canDisable: true }); + const guardInstance = new Guard(mockSettings, reflector); + expect(guardInstance.canActivate(mockContext)).toBeTruthy(); + }); + + it('should respect disableGuard callback', () => { + const mockContext = { + getHandler: jest.fn(), + getClass: jest.fn(), + } as unknown as ExecutionContext; + + const mockSettings = { enableGuards: true, disableGuard: () => true }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + const Guard = AuthGuard('local', { canDisable: true }); + const guardInstance = new Guard(mockSettings, reflector); + expect(guardInstance.canActivate(mockContext)).toBeTruthy(); + }); + }); diff --git a/packages/nestjs-authentication/src/index.spec.ts b/packages/nestjs-authentication/src/index.spec.ts new file mode 100644 index 000000000..ba5e6a8bc --- /dev/null +++ b/packages/nestjs-authentication/src/index.spec.ts @@ -0,0 +1,35 @@ +import { + AuthUser, + AuthPublic, + AuthenticationJwtResponseDto, + IssueTokenService, + VerifyTokenService, + ValidateUserService, +} from './index'; + +describe('Authentication Module Exports', () => { + it('AuthUser should be a function', () => { + expect(AuthUser).toBeInstanceOf(Function); + }); + + it('AuthPublic should be a function', () => { + expect(AuthPublic).toBeInstanceOf(Function); + }); + + it('AuthenticationJwtResponseDto should be a function', () => { + expect(AuthenticationJwtResponseDto).toBeInstanceOf(Function); + }); + + it('IssueTokenService should be a class', () => { + expect(IssueTokenService).toBeInstanceOf(Function); + }); + + it('VerifyTokenService should be a class', () => { + expect(VerifyTokenService).toBeInstanceOf(Function); + }); + + it('ValidateUserService should be a class', () => { + expect(ValidateUserService).toBeInstanceOf(Function); + }); +}); + diff --git a/packages/nestjs-common/package.json b/packages/nestjs-common/package.json index ec37e83da..ceb7fb7c9 100644 --- a/packages/nestjs-common/package.json +++ b/packages/nestjs-common/package.json @@ -21,5 +21,9 @@ "peerDependencies": { "class-transformer": "*", "class-validator": "*" + }, + "devDependencies": { + "@nestjs/testing": "^9.0.0", + "jest-mock-extended": "^2.0.4" } } diff --git a/packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts b/packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts new file mode 100644 index 000000000..32ee39ad2 --- /dev/null +++ b/packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts @@ -0,0 +1,63 @@ +import { ExecutionContext } from '@nestjs/common'; +import { mock } from 'jest-mock-extended'; +import { AuthUser } from './auth-user.decorator'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { HttpArgumentsHost } from '@nestjs/common/interfaces'; + +interface UserInterface { + user: { username: string }; +} + +describe(AuthUser.name, () => { + interface ValueInterface {} + + const getParamDecoratorFactory = (decorator: Function) => { + class TestController { + public test(@decorator() value: ValueInterface) { + return value; + } + } + + const args = Reflect.getMetadata( + ROUTE_ARGS_METADATA, + TestController, + 'test', + ); + return args[Object.keys(args)[0]].factory; + }; + const factory = getParamDecoratorFactory(AuthUser); + const context = mock(); + const httpArgumentsHost = mock(); + const testUser = { username: 'my_username' }; + + jest.spyOn(httpArgumentsHost, 'getRequest').mockImplementation(() => { + return { user: testUser } as UserInterface; + }); + + jest.spyOn(context, 'switchToHttp').mockImplementation(() => { + return httpArgumentsHost; + }); + + it('should match username', async () => { + const result = factory(null, context); + expect(result.username).toBe(testUser.username); + }); + + it('should get property of the user', async () => { + const result = factory('username', context); + expect(result).toBe(testUser.username); + }); + + it('should get property undefined ', async () => { + jest.spyOn(httpArgumentsHost, 'getRequest').mockImplementation(() => { + return { user: undefined }; + }); + const result = factory('username', context); + expect(result).toBe(undefined); + }); + + it('should get property undefined ', async () => { + const result = factory('email', context); + expect(result).toBe(undefined); + }); +}); diff --git a/packages/nestjs-common/src/index.spec.ts b/packages/nestjs-common/src/index.spec.ts new file mode 100644 index 000000000..0aef420f7 --- /dev/null +++ b/packages/nestjs-common/src/index.spec.ts @@ -0,0 +1,31 @@ +import { + ReferenceIdDto, + AuditDto, + CommonEntityDto, + AuthUser, + createSettingsProvider, +} from './index'; + +describe('Module Exports', () => { + it('ReferenceIdDto should be a class', () => { + expect(ReferenceIdDto).toBeInstanceOf(Function); + }); + + it('AuditDto should be a class', () => { + expect(AuditDto).toBeInstanceOf(Function); + }); + + it('CommonEntityDto should be a class', () => { + expect(CommonEntityDto).toBeInstanceOf(Function); + }); + + // Decorators are functions + it('AuthUser decorator should be a function', () => { + expect(AuthUser).toBeInstanceOf(Function); + }); + + // Utility functions + it('createSettingsProvider should be a function', () => { + expect(createSettingsProvider).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts b/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts new file mode 100644 index 000000000..41d2ec63d --- /dev/null +++ b/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts @@ -0,0 +1,76 @@ +import { FactoryProvider } from '@nestjs/common'; +import { createSettingsProvider } from './create-settings-provider'; + +describe(createSettingsProvider.name, () => { + // Mock tokens and keys + const settingsKey = 'exampleSettingsKey'; + const settingsToken = 'exampleSettingsToken'; + const optionsToken = 'exampleOptionsToken'; + + // Mock options and settings + const defaultSettings = { key: 'default' }; + const newSettings = { key: 'new' }; + + // Example options object + const options = { + settingsKey, + settingsToken, + optionsToken, + optionsOverrides: { + settings: newSettings, + settingsTransform: jest.fn((effective, defaults) => ({ + ...defaults, + ...effective, + })), + }, + }; + + it('returns a correct provider object', () => { + const provider = createSettingsProvider(options) as FactoryProvider; + expect(provider.provide).toBe(settingsToken); + expect(provider.inject).toEqual([optionsToken, settingsKey]); + expect(typeof provider.useFactory).toBe('function'); + }); + + it('useFactory returns effective settings when overrides are provided', async () => { + const provider = createSettingsProvider(options) as FactoryProvider; + const settings = await provider.useFactory( + { settings: defaultSettings }, + defaultSettings, + ); + expect(settings).toEqual(newSettings); + }); + + it('useFactory uses default settings when no overrides are provided', async () => { + const provider = createSettingsProvider({ + ...options, + optionsOverrides: undefined, + }) as FactoryProvider; + const settings = await provider.useFactory( + { settings: defaultSettings }, + defaultSettings, + ); + expect(settings).toEqual(defaultSettings); + }); + + it('calls settingsTransform function when provided', async () => { + const provider = createSettingsProvider(options) as FactoryProvider; + await provider.useFactory({ settings: defaultSettings }, defaultSettings); + expect(options.optionsOverrides.settingsTransform).toHaveBeenCalledWith( + newSettings, + defaultSettings, + ); + }); + + it('calls settingsTransform function when provided', async () => { + const provider = createSettingsProvider({ + ...options, + optionsOverrides: undefined, + }) as FactoryProvider; + const settings = await provider.useFactory( + { settings: undefined }, + defaultSettings, + ); + expect(settings).toEqual(defaultSettings); + }); +}); diff --git a/packages/nestjs-core/src/index.spec.ts b/packages/nestjs-core/src/index.spec.ts new file mode 100644 index 000000000..4a994aef2 --- /dev/null +++ b/packages/nestjs-core/src/index.spec.ts @@ -0,0 +1,13 @@ +import { + createConfigurableDynamicRootModule, + deferExternal, + negotiateController +} from './index'; + +describe('index', () => { + it('should export functions', () => { + expect(createConfigurableDynamicRootModule).toBeInstanceOf(Function); + expect(deferExternal).toBeInstanceOf(Function); + expect(negotiateController).toBeInstanceOf(Function); + }); +}); \ No newline at end of file diff --git a/packages/nestjs-core/src/modules/defer-external.spec.ts b/packages/nestjs-core/src/modules/defer-external.spec.ts new file mode 100644 index 000000000..f3b6e31ca --- /dev/null +++ b/packages/nestjs-core/src/modules/defer-external.spec.ts @@ -0,0 +1,79 @@ + +import { DynamicModule, Type } from '@nestjs/common'; +import { IConfigurableDynamicRootModule } from '@golevelup/nestjs-modules'; +import { DeferExternalOptionsInterface } from './interfaces/defer-external-options.interface'; +import { deferExternal } from './defer-external'; + +interface TestModule + extends IConfigurableDynamicRootModule< + TestModule, + DeferExternalOptionsInterface + >, + Type { + externallyConfigured: ( + module: TestModule, + timeout: number, + ) => Promise; +} + +describe(deferExternal.name, () => { + let moduleCtorMock: TestModule; + + beforeEach(() => { + moduleCtorMock = { + externallyConfigured: jest.fn(), + } as unknown as TestModule; + }); + + it('should use default timeout when no environment variable set', async () => { + delete process.env.ROCKETS_MODULE_DEFERRED_TIMEOUT; + const options = { timeout: 5000 }; + (moduleCtorMock.externallyConfigured as jest.Mock).mockResolvedValue( + 'ExpectedModule', + ); + + const result = await deferExternal(moduleCtorMock, options); + + expect(moduleCtorMock.externallyConfigured).toHaveBeenCalledWith( + moduleCtorMock, + 5000, + ); + expect(result).toBe('ExpectedModule'); + }); + + it('should use environment variable timeout when set', async () => { + process.env.ROCKETS_MODULE_DEFERRED_TIMEOUT = '3000'; + const options = {}; + (moduleCtorMock.externallyConfigured as jest.Mock).mockResolvedValue( + 'ExpectedModule', + ); + + const result = await deferExternal(moduleCtorMock, options); + + expect(moduleCtorMock.externallyConfigured).toHaveBeenCalledWith( + moduleCtorMock, + 3000, + ); + expect(result).toBe('ExpectedModule'); + }); + + it('should throw custom error when externallyConfigured fails and timeoutMessage is provided', async () => { + const options = { timeoutMessage: 'Custom Error:' }; + const error = new Error('Original Error'); + (moduleCtorMock.externallyConfigured as jest.Mock).mockRejectedValue(error); + + await expect(deferExternal(moduleCtorMock, options)).rejects.toThrow( + 'Custom Error: Original Error', + ); + }); + + it('should propagate the error from externallyConfigured when no timeoutMessage is provided', async () => { + const options = {}; + const error = new Error('Failure'); + (moduleCtorMock.externallyConfigured as jest.Mock).mockRejectedValue(error); + + await expect(deferExternal(moduleCtorMock, options)).rejects.toThrow( + 'Failure', + ); + }); +}); diff --git a/packages/nestjs-core/src/modules/negotiate-controller.spec.ts b/packages/nestjs-core/src/modules/negotiate-controller.spec.ts new file mode 100644 index 000000000..efec14a2a --- /dev/null +++ b/packages/nestjs-core/src/modules/negotiate-controller.spec.ts @@ -0,0 +1,46 @@ +import { DynamicModule } from "@nestjs/common"; +import { ModuleOptionsControllerInterface } from "./interfaces/module-options-controller.interface"; +import { negotiateController } from "./negotiate-controller"; + +class TestController {} // Example controller class for testing + +describe(negotiateController.name, () => { + let moduleMock: DynamicModule; + + beforeEach(() => { + moduleMock = { + module: TestController, + controllers: [], + }; + }); + + it('should remove all controllers if options.controller is false', () => { + const options: ModuleOptionsControllerInterface = { controller: false }; + negotiateController(moduleMock, options); + expect(moduleMock.controllers).toEqual([]); + }); + + it('should set a single controller if options.controller is a single controller class', () => { + const options: ModuleOptionsControllerInterface = { + controller: TestController, + }; + negotiateController(moduleMock, options); + expect(moduleMock.controllers).toEqual([TestController]); + }); + + it('should set multiple controllers if options.controller is an array of controller classes', () => { + const options: ModuleOptionsControllerInterface = { + controller: [TestController, TestController], + }; + negotiateController(moduleMock, options); + expect(moduleMock.controllers).toEqual([TestController, TestController]); + }); + + it('should not change controllers if options.controller is undefined', () => { + const initialControllers = [TestController]; + moduleMock.controllers = initialControllers; + const options: ModuleOptionsControllerInterface = {}; // options.controller is undefined + negotiateController(moduleMock, options); + expect(moduleMock.controllers).toBe(initialControllers); + }); +}); diff --git a/packages/nestjs-exception/src/constants/error-codes.constants.spec.ts b/packages/nestjs-exception/src/constants/error-codes.constants.spec.ts new file mode 100644 index 000000000..55342f1bb --- /dev/null +++ b/packages/nestjs-exception/src/constants/error-codes.constants.spec.ts @@ -0,0 +1,28 @@ +import { + ERROR_CODE_UNKNOWN, + ERROR_CODE_HTTP_UNKNOWN, + ERROR_CODE_HTTP_BAD_REQUEST, + ERROR_CODE_HTTP_NOT_FOUND, + ERROR_CODE_HTTP_INTERNAL_SERVER_ERROR, + HTTP_ERROR_CODE, +} from './error-codes.constants'; + +describe('Error Codes Constants', () => { + it('should export the correct error codes', () => { + expect(ERROR_CODE_UNKNOWN).toBe('UNKNOWN'); + expect(ERROR_CODE_HTTP_UNKNOWN).toBe('HTTP_UNKNOWN'); + expect(ERROR_CODE_HTTP_BAD_REQUEST).toBe('HTTP_BAD_REQUEST'); + expect(ERROR_CODE_HTTP_NOT_FOUND).toBe('HTTP_NOT_FOUND'); + expect(ERROR_CODE_HTTP_INTERNAL_SERVER_ERROR).toBe( + 'HTTP_INTERNAL_SERVER_ERROR', + ); + }); + + it('should have the correct HTTP error codes in the map', () => { + expect(HTTP_ERROR_CODE.get(400)).toBe(ERROR_CODE_HTTP_BAD_REQUEST); + expect(HTTP_ERROR_CODE.get(404)).toBe(ERROR_CODE_HTTP_NOT_FOUND); + expect(HTTP_ERROR_CODE.get(500)).toBe( + ERROR_CODE_HTTP_INTERNAL_SERVER_ERROR, + ); + }); +}); diff --git a/packages/nestjs-exception/src/utils/map-http-status.util.spec.ts b/packages/nestjs-exception/src/utils/map-http-status.util.spec.ts new file mode 100644 index 000000000..07169c95a --- /dev/null +++ b/packages/nestjs-exception/src/utils/map-http-status.util.spec.ts @@ -0,0 +1,22 @@ +import { mapHttpStatus } from './map-http-status.util'; +import { ERROR_CODE_HTTP_UNKNOWN } from '../constants/error-codes.constants'; + +describe(mapHttpStatus.name, () => { + it('should return the error code for a given status code', () => { + const statusCode = 404; + const expectedErrorCode = 'HTTP_NOT_FOUND'; + + const result = mapHttpStatus(statusCode); + + expect(result).toBe(expectedErrorCode); + }); + + it('should return the unknown error code for an unknown status code', () => { + const statusCode = 999; + const expectedErrorCode = ERROR_CODE_HTTP_UNKNOWN; + + const result = mapHttpStatus(statusCode); + + expect(result).toBe(expectedErrorCode); + }); +}); \ No newline at end of file diff --git a/packages/nestjs-federated/src/index.spec.ts b/packages/nestjs-federated/src/index.spec.ts new file mode 100644 index 000000000..8a137916c --- /dev/null +++ b/packages/nestjs-federated/src/index.spec.ts @@ -0,0 +1,58 @@ +import { + FederatedModule, + FederatedPostgresEntity, + FederatedSqliteEntity, + FederatedService, + FederatedOAuthService, + FederatedDto, + FederatedCreateDto, + FederatedUpdateDto, +} from './index'; + +describe('Federated Module', () => { + it('should be a function', () => { + expect(FederatedModule).toBeInstanceOf(Function); + }); +}); + +describe('Federated Postgres Entity', () => { + it('should be a function', () => { + expect(FederatedPostgresEntity).toBeInstanceOf(Function); + }); +}); + +describe('Federated Sqlite Entity', () => { + it('should be a function', () => { + expect(FederatedSqliteEntity).toBeInstanceOf(Function); + }); +}); + +describe('Federated Service', () => { + it('should be a function', () => { + expect(FederatedService).toBeInstanceOf(Function); + }); +}); + +describe('Federated OAuth Service', () => { + it('should be a function', () => { + expect(FederatedOAuthService).toBeInstanceOf(Function); + }); +}); + +describe('Federated Dto', () => { + it('should be a function', () => { + expect(FederatedDto).toBeInstanceOf(Function); + }); +}); + +describe('Federated Create Dto', () => { + it('should be a function', () => { + expect(FederatedCreateDto).toBeInstanceOf(Function); + }); +}); + +describe('Federated Update Dto', () => { + it('should be a function', () => { + expect(FederatedUpdateDto).toBeInstanceOf(Function); + }); +}); \ No newline at end of file diff --git a/packages/nestjs-otp/src/exceptions/entity-not-found.exception.spec.ts b/packages/nestjs-otp/src/exceptions/entity-not-found.exception.spec.ts new file mode 100644 index 000000000..d676f4714 --- /dev/null +++ b/packages/nestjs-otp/src/exceptions/entity-not-found.exception.spec.ts @@ -0,0 +1,25 @@ +import { EntityNotFoundException } from './entity-not-found.exception'; + +describe(EntityNotFoundException.name, () => { + it('should create an instance of EntityNotFoundException', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception).toBeInstanceOf(EntityNotFoundException); + }); + + it('should have the correct error message', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception.message).toBe( + 'Entity TestEntity was not registered to be used.', + ); + }); + + it('should have the correct context', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception.context).toEqual({ entityName: 'TestEntity' }); + }); + + it('should have the correct error code', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception.errorCode).toBe('OTP_ENTITY_NOT_FOUND_ERROR'); + }); +}); diff --git a/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts b/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts new file mode 100644 index 000000000..0866efe05 --- /dev/null +++ b/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts @@ -0,0 +1,25 @@ +import { OtpTypeNotDefinedException } from './otp-type-not-defined.exception'; + +describe(OtpTypeNotDefinedException.name, () => { + it('should create an instance of OtpTypeNotDefinedException', () => { + const exception = new OtpTypeNotDefinedException('test'); + expect(exception).toBeInstanceOf(OtpTypeNotDefinedException); + }); + + it('should have the correct error code', () => { + const exception = new OtpTypeNotDefinedException('test'); + expect(exception.errorCode).toBe('OTP_TYPE_NOT_DEFINED_ERROR'); + }); + + it('should have the correct context', () => { + const exception = new OtpTypeNotDefinedException('test'); + expect(exception.context).toEqual({ type: 'test' }); + }); + + it('should have the correct message', () => { + const exception = new OtpTypeNotDefinedException('test'); + expect(exception.message).toBe( + 'Type test was not defined to be used. please check config.', + ); + }); +}); \ No newline at end of file diff --git a/packages/nestjs-otp/src/index.spec.ts b/packages/nestjs-otp/src/index.spec.ts new file mode 100644 index 000000000..a2ffdd254 --- /dev/null +++ b/packages/nestjs-otp/src/index.spec.ts @@ -0,0 +1,29 @@ +import { + OtpModule, + OtpService, + OtpPostgresEntity, + OtpSqliteEntity, + OtpCreateDto, +} from './index'; + +describe('index', () => { + it('should be an instance of Function', () => { + expect(OtpModule).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(OtpService).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(OtpPostgresEntity).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(OtpSqliteEntity).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(OtpCreateDto).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-otp/src/otp.module.spec.ts b/packages/nestjs-otp/src/otp.module.spec.ts index 1426d305c..a50a09535 100644 --- a/packages/nestjs-otp/src/otp.module.spec.ts +++ b/packages/nestjs-otp/src/otp.module.spec.ts @@ -6,8 +6,9 @@ import { OtpModule } from './otp.module'; import { OtpService } from './services/otp.service'; import { AppModuleFixture } from './__fixtures__/app.module.fixture'; +import { DynamicModule } from '@nestjs/common'; -describe('OtpModule', () => { +describe(OtpModule.name, () => { let otpModule: OtpModule; let otpService: OtpService; let otpDynamicRepo: Record>; @@ -34,4 +35,34 @@ describe('OtpModule', () => { expect(otpDynamicRepo).toBeDefined(); }); }); + + describe('OtpModule functions', () => { + const spyRegister = jest + .spyOn(OtpModule, 'register') + .mockImplementation(() => { + return {} as DynamicModule; + }); + + const spyRegisterAsync = jest + .spyOn(OtpModule, 'registerAsync') + .mockImplementation(() => { + return {} as DynamicModule; + }); + + it('should call super.register in register method', () => { + OtpModule.register({}); + expect(spyRegister).toHaveBeenCalled(); + }); + + it('should call super.registerAsync in register method', () => { + OtpModule.registerAsync({}); + expect(spyRegisterAsync).toHaveBeenCalled(); + }); + + it('should throw an error in forFeature method', () => { + expect(() => OtpModule.forFeature({})).toThrow( + 'You must provide the entities option', + ); + }); + }); }); diff --git a/packages/typeorm-common/package.json b/packages/typeorm-common/package.json index 930e9999e..7fb3bf3b9 100644 --- a/packages/typeorm-common/package.json +++ b/packages/typeorm-common/package.json @@ -21,7 +21,8 @@ "@concepta/nestjs-typeorm-ext": "^4.0.0-alpha.42", "@concepta/typeorm-seeding": "^4.0.0-beta.0", "@faker-js/faker": "6.0.0-alpha.6", - "@nestjs/typeorm": "^9.0.0" + "@nestjs/typeorm": "^9.0.0", + "jest-mock-extended": "^2.0.4" }, "peerDependencies": { "@nestjs/testing": "^9.0.0", diff --git a/packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts b/packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts new file mode 100644 index 000000000..1d91fed2a --- /dev/null +++ b/packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts @@ -0,0 +1,52 @@ +import { EntityManager, Repository } from 'typeorm'; +import { mock } from 'jest-mock-extended'; +import { EntityManagerProxy } from './entity-manager.proxy'; +import { TransactionProxy } from './transaction.proxy'; + +class TestEntity {} +describe(EntityManagerProxy.name, () => { + let entityManager: EntityManager; + let entityManagerProxy: EntityManagerProxy; + let repositoryMock: Repository; + + beforeEach(() => { + entityManager = mock(); + entityManagerProxy = new EntityManagerProxy(entityManager); + repositoryMock = mock>(); + }); + + describe('entityManager()', () => { + it('should return the injected EntityManager', () => { + const result = entityManagerProxy.entityManager(); + expect(result).toBe(entityManager); + }); + }); + + describe('repository()', () => { + it('should return the original repository if no options provided', () => { + const result = entityManagerProxy.repository(repositoryMock); + expect(result).toBe(repositoryMock); + }); + + it('should return a repository from a transaction if transaction option provided', async () => { + const transactionProxy = mock(); + const transactionRepository = mock>(); + await transactionProxy.repository(repositoryMock); + + jest.spyOn(transactionProxy, 'repository').mockImplementationOnce(() => { + return transactionRepository; + }); + + const options = { transaction: transactionProxy }; + const result = entityManagerProxy.repository(repositoryMock, options); + expect(result).toBe(transactionRepository); + }); + }); + + describe('transaction()', () => { + it('should create a TransactionProxy with the EntityManager', () => { + const result = entityManagerProxy.transaction(); + expect(result).toBeInstanceOf(TransactionProxy); + }); + }); +}); From 0b275073042990c0fa56b7ef8f19a07b2880fee6 Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Tue, 14 May 2024 10:53:58 -0300 Subject: [PATCH 2/2] feat: cache module --- packages/nestjs-cache/README.md | 54 +++ packages/nestjs-cache/package.json | 43 +++ .../src/__fixtures__/app.module.fixture.ts | 33 ++ .../entities/user-cache-entity.fixture.ts | 13 + .../entities/user-entity.fixture.ts | 18 + .../factories/user-cache.factory.fixture.ts | 21 ++ .../factories/user.factory.fixture.ts | 8 + packages/nestjs-cache/src/cache.constants.ts | 8 + packages/nestjs-cache/src/cache.factory.ts | 43 +++ .../src/cache.module-definition.ts | 171 ++++++++++ .../nestjs-cache/src/cache.module.spec.ts | 69 ++++ packages/nestjs-cache/src/cache.module.ts | 47 +++ packages/nestjs-cache/src/cache.seeder.ts | 23 ++ packages/nestjs-cache/src/cache.types.spec.ts | 10 + packages/nestjs-cache/src/cache.types.ts | 4 + .../src/config/cache-default.config.ts | 13 + .../cache-crud.controller.e2e-spec.ts | 172 ++++++++++ .../src/controllers/cache-crud.controller.ts | 203 +++++++++++ .../nestjs-cache/src/dto/cache-create.dto.ts | 18 + .../src/dto/cache-paginated.dto.ts | 19 ++ .../nestjs-cache/src/dto/cache-update.dto.ts | 11 + packages/nestjs-cache/src/dto/cache.dto.ts | 56 ++++ .../src/entities/cache-postgres.entity.ts | 30 ++ .../src/entities/cache-sqlite.entity.ts | 31 ++ .../assignment-not-found.exception.spec.ts | 26 ++ .../assignment-not-found.exception.ts | 23 ++ .../cache-type-not-defined.exception.spec.ts | 25 ++ .../cache-type-not-defined.exception.ts | 23 ++ .../entity-not-found.exception.spec.ts | 25 ++ .../exceptions/entity-not-found.exception.ts | 23 ++ packages/nestjs-cache/src/index.spec.ts | 29 ++ packages/nestjs-cache/src/index.ts | 9 + .../cache-entities-options.interface.ts | 5 + .../cache-options-extras.interface.ts | 6 + .../src/interfaces/cache-options.interface.ts | 5 + .../src/interfaces/cache-service.interface.ts | 23 ++ .../interfaces/cache-settings.interface.ts | 6 + packages/nestjs-cache/src/seeding.ts | 7 + .../src/services/cache-crud.service.ts | 18 + .../src/services/cache.service.spec.ts | 140 ++++++++ .../src/services/cache.service.ts | 317 ++++++++++++++++++ packages/nestjs-cache/tsconfig.json | 15 + .../cache/interfaces/cache-clear.interface.ts | 21 ++ .../interfaces/cache-creatable.interface.ts | 6 + .../interfaces/cache-create.interface.ts | 23 ++ .../interfaces/cache-delete.interface.ts | 20 ++ .../interfaces/cache-get-one.interface.ts | 20 ++ .../interfaces/cache-updatable.interface.ts | 4 + .../interfaces/cache-update.interface.ts | 21 ++ .../src/cache/interfaces/cache.interface.ts | 30 ++ packages/ts-common/src/index.ts | 9 + 51 files changed, 1997 insertions(+) create mode 100644 packages/nestjs-cache/README.md create mode 100644 packages/nestjs-cache/package.json create mode 100644 packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts create mode 100644 packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts create mode 100644 packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts create mode 100644 packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts create mode 100644 packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts create mode 100644 packages/nestjs-cache/src/cache.constants.ts create mode 100644 packages/nestjs-cache/src/cache.factory.ts create mode 100644 packages/nestjs-cache/src/cache.module-definition.ts create mode 100644 packages/nestjs-cache/src/cache.module.spec.ts create mode 100644 packages/nestjs-cache/src/cache.module.ts create mode 100644 packages/nestjs-cache/src/cache.seeder.ts create mode 100644 packages/nestjs-cache/src/cache.types.spec.ts create mode 100644 packages/nestjs-cache/src/cache.types.ts create mode 100644 packages/nestjs-cache/src/config/cache-default.config.ts create mode 100644 packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts create mode 100644 packages/nestjs-cache/src/controllers/cache-crud.controller.ts create mode 100644 packages/nestjs-cache/src/dto/cache-create.dto.ts create mode 100644 packages/nestjs-cache/src/dto/cache-paginated.dto.ts create mode 100644 packages/nestjs-cache/src/dto/cache-update.dto.ts create mode 100644 packages/nestjs-cache/src/dto/cache.dto.ts create mode 100644 packages/nestjs-cache/src/entities/cache-postgres.entity.ts create mode 100644 packages/nestjs-cache/src/entities/cache-sqlite.entity.ts create mode 100644 packages/nestjs-cache/src/exceptions/assignment-not-found.exception.spec.ts create mode 100644 packages/nestjs-cache/src/exceptions/assignment-not-found.exception.ts create mode 100644 packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.spec.ts create mode 100644 packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.ts create mode 100644 packages/nestjs-cache/src/exceptions/entity-not-found.exception.spec.ts create mode 100644 packages/nestjs-cache/src/exceptions/entity-not-found.exception.ts create mode 100644 packages/nestjs-cache/src/index.spec.ts create mode 100644 packages/nestjs-cache/src/index.ts create mode 100644 packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts create mode 100644 packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts create mode 100644 packages/nestjs-cache/src/interfaces/cache-options.interface.ts create mode 100644 packages/nestjs-cache/src/interfaces/cache-service.interface.ts create mode 100644 packages/nestjs-cache/src/interfaces/cache-settings.interface.ts create mode 100644 packages/nestjs-cache/src/seeding.ts create mode 100644 packages/nestjs-cache/src/services/cache-crud.service.ts create mode 100644 packages/nestjs-cache/src/services/cache.service.spec.ts create mode 100644 packages/nestjs-cache/src/services/cache.service.ts create mode 100644 packages/nestjs-cache/tsconfig.json create mode 100644 packages/ts-common/src/cache/interfaces/cache-clear.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache-creatable.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache-create.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache-delete.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache-get-one.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache-updatable.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache-update.interface.ts create mode 100644 packages/ts-common/src/cache/interfaces/cache.interface.ts diff --git a/packages/nestjs-cache/README.md b/packages/nestjs-cache/README.md new file mode 100644 index 000000000..c4e6565da --- /dev/null +++ b/packages/nestjs-cache/README.md @@ -0,0 +1,54 @@ +# Rockets NestJS Cache + +A module for managing a basic Cache entity, including controller with full CRUD, DTOs, sample data factory and seeder. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) +[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) +[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) +[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Installation + +`yarn add @concepta/nestjs-cache` + +## Usage + +```ts +// ... +import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { CacheModule } from '@concepta/nestjs-user'; +import { CrudModule } from '@concepta/nestjs-crud'; + +@Module({ + imports: [ + TypeOrmExtModule.forRoot({ + type: 'postgres', + url: 'postgres://user:pass@localhost:5432/postgres', + }), + CrudModule.forRoot({}), + CacheModule.forRoot({}), + ], +}) +export class AppModule {} +``` + +## Configuration + +- [Seeding](#seeding) + - [ENV](#env) + +### Seeding + +Configurations specific to (optional) database seeding. + +#### ENV + +Configurations available via environment. + +| Variable | Type | Default | | +| -------------------------- | ---------- | ------- | ------------------------------------ | +| `CACHE_MODULE_SEEDER_AMOUNT` | `` | `50` | number of additional users to create | +| `CACHE_EXPIRE_IN` | `` | `1d` | string for the amount of time to expire the cache | diff --git a/packages/nestjs-cache/package.json b/packages/nestjs-cache/package.json new file mode 100644 index 000000000..f045abbbb --- /dev/null +++ b/packages/nestjs-cache/package.json @@ -0,0 +1,43 @@ +{ + "name": "@concepta/nestjs-cache", + "version": "4.0.0-alpha.42", + "description": "Rockets NestJS User", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + ], + "dependencies": { + "@concepta/nestjs-access-control": "^4.0.0-alpha.42", + "@concepta/nestjs-common": "^4.0.0-alpha.42", + "@concepta/nestjs-core": "^4.0.0-alpha.42", + "@concepta/nestjs-crud": "^4.0.0-alpha.42", + "@concepta/nestjs-typeorm-ext": "^4.0.0-alpha.42", + "@concepta/ts-common": "^4.0.0-alpha.42", + "@concepta/ts-core": "^4.0.0-alpha.42", + "@concepta/typeorm-common": "^4.0.0-alpha.42", + "@nestjs/common": "^9.0.0", + "@nestjs/config": "^2.2.0", + "@nestjs/swagger": "^6.0.0", + "ms": "^2.1.3" + }, + "devDependencies": { + "@concepta/typeorm-seeding": "^4.0.0-beta.0", + "@faker-js/faker": "^6.0.0-alpha.6", + "@nestjs/testing": "^9.0.0", + "@nestjs/typeorm": "^9.0.0", + "@types/ms": "^0.7.31", + "@types/supertest": "^2.0.11", + "jest-mock-extended": "^2.0.4", + "supertest": "^6.1.3" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "typeorm": "^0.3.0" + } +} diff --git a/packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts new file mode 100644 index 000000000..6335919e2 --- /dev/null +++ b/packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts @@ -0,0 +1,33 @@ +import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { Module } from '@nestjs/common'; + +import { CacheModule } from '../cache.module'; +import { UserEntityFixture } from './entities/user-entity.fixture'; +import { UserCacheEntityFixture } from './entities/user-cache-entity.fixture'; +import { CrudModule } from '@concepta/nestjs-crud'; + +@Module({ + imports: [ + TypeOrmExtModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [UserEntityFixture, UserCacheEntityFixture], + }), + CacheModule.register({ + settings: { + assignments: { + user: { entityKey: 'userCache' }, + }, + }, + entities: { + userCache: { + entity: UserCacheEntityFixture, + }, + }, + }), + CrudModule.forRoot({}), + ], +}) +export class AppModuleFixture { } + diff --git a/packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts b/packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts new file mode 100644 index 000000000..d1c0ccee4 --- /dev/null +++ b/packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts @@ -0,0 +1,13 @@ +import { Entity, ManyToOne } from 'typeorm'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { UserEntityFixture } from './user-entity.fixture'; +import { CacheSqliteEntity } from '../../entities/cache-sqlite.entity'; + +/** + * Cache Entity Fixture + */ +@Entity() +export class UserCacheEntityFixture extends CacheSqliteEntity { + @ManyToOne(() => UserEntityFixture, (user) => user.userCaches) + assignee!: ReferenceIdInterface; +} diff --git a/packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts b/packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts new file mode 100644 index 000000000..3009c3b0e --- /dev/null +++ b/packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts @@ -0,0 +1,18 @@ +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { UserCacheEntityFixture } from './user-cache-entity.fixture'; + +/** + * User Entity Fixture + */ +@Entity() +export class UserEntityFixture implements ReferenceIdInterface { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ default: false }) + isActive!: boolean; + + @OneToMany(() => UserCacheEntityFixture, (userCache) => userCache.assignee) + userCaches!: UserCacheEntityFixture[]; +} diff --git a/packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts b/packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts new file mode 100644 index 000000000..1bcda1575 --- /dev/null +++ b/packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts @@ -0,0 +1,21 @@ + +import { Factory } from '@concepta/typeorm-seeding'; +import { UserCacheEntityFixture } from '../entities/user-cache-entity.fixture'; +import Faker from '@faker-js/faker'; + +export class UserCacheFactoryFixture extends Factory { + protected options = { + entity: UserCacheEntityFixture, + }; + + protected async entity( + userCache: UserCacheEntityFixture, + ): Promise { + userCache.key = Faker.name.jobArea(); + userCache.type = 'filter'; + userCache.data = JSON.stringify({ sortBy: 'name' }); + userCache.expirationDate = new Date(); + + return userCache; + } +} diff --git a/packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts b/packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts new file mode 100644 index 000000000..f1d79744a --- /dev/null +++ b/packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts @@ -0,0 +1,8 @@ +import { Factory } from '@concepta/typeorm-seeding'; +import { UserEntityFixture } from '../entities/user-entity.fixture'; + +export class UserFactoryFixture extends Factory { + options = { + entity: UserEntityFixture, + }; +} diff --git a/packages/nestjs-cache/src/cache.constants.ts b/packages/nestjs-cache/src/cache.constants.ts new file mode 100644 index 000000000..618288495 --- /dev/null +++ b/packages/nestjs-cache/src/cache.constants.ts @@ -0,0 +1,8 @@ +export const CACHE_MODULE_SETTINGS_TOKEN = 'CACHE_MODULE_SETTINGS_TOKEN'; +export const CACHE_MODULE_REPOSITORIES_TOKEN = + 'CACHE_MODULE_REPOSITORIES_TOKEN'; +export const CACHE_MODULE_CRUD_SERVICES_TOKEN = + 'CACHE_MODULE_CRUD_SERVICES_TOKEN'; +export const CACHE_MODULE_DEFAULT_SETTINGS_TOKEN = + 'CACHE_MODULE_DEFAULT_SETTINGS_TOKEN'; +export const CACHE_MODULE_CACHE_ENTITY_KEY = 'cache'; diff --git a/packages/nestjs-cache/src/cache.factory.ts b/packages/nestjs-cache/src/cache.factory.ts new file mode 100644 index 000000000..bc929b55b --- /dev/null +++ b/packages/nestjs-cache/src/cache.factory.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'crypto'; +import Faker from '@faker-js/faker'; +import { Factory } from '@concepta/typeorm-seeding'; +import { CacheInterface } from '@concepta/ts-common'; +/** + * Cache factory + */ +export class CacheFactory extends Factory { + /** + * List of used names. + */ + keys: string[] = ['filter', 'sort', 'list']; + + /** + * Factory callback function. + */ + protected async entity(cache: CacheInterface): Promise { + const fakeFilter = { + name: Faker.name.firstName, + orderBy: 'name', + }; + + // set the name + cache.key = randomUUID(); + cache.type = this.randomKey(); + cache.data = JSON.stringify(fakeFilter); + cache.expirationDate = new Date(); + + // return the new cache + return cache; + } + + /** + * Get a random category. + */ + protected randomKey(): string { + // random index + const randomIdx = Math.floor(Math.random() * this.keys.length); + + // return it + return this.keys[randomIdx]; + } +} diff --git a/packages/nestjs-cache/src/cache.module-definition.ts b/packages/nestjs-cache/src/cache.module-definition.ts new file mode 100644 index 000000000..8938b158d --- /dev/null +++ b/packages/nestjs-cache/src/cache.module-definition.ts @@ -0,0 +1,171 @@ +import { + ConfigurableModuleBuilder, + DynamicModule, + Provider, +} from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { createSettingsProvider } from '@concepta/nestjs-common'; +import { + getDynamicRepositoryToken, + TypeOrmExtModule, +} from '@concepta/nestjs-typeorm-ext'; + +import { + CACHE_MODULE_CRUD_SERVICES_TOKEN, + CACHE_MODULE_REPOSITORIES_TOKEN, + CACHE_MODULE_SETTINGS_TOKEN, +} from './cache.constants'; + +import { CacheEntitiesOptionsInterface } from './interfaces/cache-entities-options.interface'; +import { CacheOptionsExtrasInterface } from './interfaces/cache-options-extras.interface'; +import { CacheOptionsInterface } from './interfaces/cache-options.interface'; +import { CacheSettingsInterface } from './interfaces/cache-settings.interface'; + +import { CacheInterface } from '@concepta/ts-common'; +import { Repository } from 'typeorm'; +import { cacheDefaultConfig } from './config/cache-default.config'; +import { CacheCrudController } from './controllers/cache-crud.controller'; +import { CacheCrudService } from './services/cache-crud.service'; +import { CacheService } from './services/cache.service'; + +const RAW_OPTIONS_TOKEN = Symbol('__CACHE_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: CacheModuleClass, + OPTIONS_TYPE: CACHE_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: CACHE_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'Cache', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras( + { global: false }, + definitionTransform, + ) + .build(); + +export type CacheOptions = Omit; +export type CacheAsyncOptions = Omit; + +function definitionTransform( + definition: DynamicModule, + extras: CacheOptionsExtrasInterface, +): DynamicModule { + const { providers } = definition; + const { controllers, global = false, entities } = extras; + + if (!entities) { + throw new Error('You must provide the entities option'); + } + + return { + ...definition, + global, + imports: createCacheImports({ entities }), + providers: createCacheProviders({ entities, providers }), + exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createCacheExports()], + controllers: createCacheControllers({ controllers }), + }; +} + +export function createCacheControllers( + overrides: Pick = {}, +): DynamicModule['controllers'] { + return overrides?.controllers !== undefined + ? overrides.controllers + : [CacheCrudController]; +} + +export function createCacheImports( + options: CacheEntitiesOptionsInterface, +): DynamicModule['imports'] { + return [ + ConfigModule.forFeature(cacheDefaultConfig), + TypeOrmExtModule.forFeature(options.entities), + ]; +} + +export function createCacheProviders( + options: CacheEntitiesOptionsInterface & { + overrides?: CacheOptions; + providers?: Provider[]; + }, +): Provider[] { + return [ + ...(options.providers ?? []), + createCacheSettingsProvider(options.overrides), + ...createCacheRepositoriesProvider({ + entities: options.overrides?.entities ?? options.entities, + }), + CacheService, + ]; +} + +export function createCacheExports(): Required< + Pick +>['exports'] { + return [ + CACHE_MODULE_SETTINGS_TOKEN, + CACHE_MODULE_REPOSITORIES_TOKEN, + CacheService, + ]; +} + +export function createCacheSettingsProvider( + optionsOverrides?: CacheOptions, +): Provider { + return createSettingsProvider({ + settingsToken: CACHE_MODULE_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: cacheDefaultConfig.KEY, + optionsOverrides, + }); +} + +export function createCacheRepositoriesProvider( + options: CacheEntitiesOptionsInterface, +): Provider[] { + const { entities } = options; + + const reposToInject = []; + const keyTracker: Record = {}; + + let entityIdx = 0; + + for (const entityKey in entities) { + reposToInject[entityIdx] = getDynamicRepositoryToken(entityKey); + keyTracker[entityKey] = entityIdx++; + } + + return [ + { + provide: CACHE_MODULE_REPOSITORIES_TOKEN, + inject: reposToInject, + useFactory: (...args: string[]) => { + const repoInstances: Record = {}; + + for (const entityKey in entities) { + repoInstances[entityKey] = args[keyTracker[entityKey]]; + } + + return repoInstances; + }, + }, + { + provide: CACHE_MODULE_CRUD_SERVICES_TOKEN, + inject: reposToInject, + useFactory: (...args: Repository[]) => { + const serviceInstances: Record = {}; + + for (const entityKey in entities) { + serviceInstances[entityKey] = new CacheCrudService( + args[keyTracker[entityKey]], + ); + } + + return serviceInstances; + }, + }, + ]; +} diff --git a/packages/nestjs-cache/src/cache.module.spec.ts b/packages/nestjs-cache/src/cache.module.spec.ts new file mode 100644 index 000000000..9ad0fa6c5 --- /dev/null +++ b/packages/nestjs-cache/src/cache.module.spec.ts @@ -0,0 +1,69 @@ +import { Repository } from 'typeorm'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheInterface } from '@concepta/ts-common'; +import { CACHE_MODULE_REPOSITORIES_TOKEN } from './cache.constants'; +import { CacheModule } from './cache.module'; + +import { AppModuleFixture } from './__fixtures__/app.module.fixture'; +import { DynamicModule } from '@nestjs/common'; +import { CacheService } from './services/cache.service'; + +describe(CacheModule.name, () => { + let cacheModule: CacheModule; + let cacheService: CacheService; + let cacheDynamicRepo: Record>; + + beforeEach(async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + cacheModule = testModule.get(CacheModule); + cacheService = testModule.get(CacheService); + cacheDynamicRepo = testModule.get< + Record> + >(CACHE_MODULE_REPOSITORIES_TOKEN); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('module', () => { + it('should be loaded', async () => { + expect(cacheModule).toBeInstanceOf(CacheModule); + expect(cacheService).toBeInstanceOf(CacheService); + expect(cacheDynamicRepo).toBeDefined(); + }); + }); + + describe('CacheModule functions', () => { + const spyRegister = jest + .spyOn(CacheModule, 'register') + .mockImplementation(() => { + return {} as DynamicModule; + }); + + const spyRegisterAsync = jest + .spyOn(CacheModule, 'registerAsync') + .mockImplementation(() => { + return {} as DynamicModule; + }); + + it('should call super.register in register method', () => { + CacheModule.register({}); + expect(spyRegister).toHaveBeenCalled(); + }); + + it('should call super.registerAsync in register method', () => { + CacheModule.registerAsync({}); + expect(spyRegisterAsync).toHaveBeenCalled(); + }); + + it('should throw an error in forFeature method', () => { + expect(() => CacheModule.forFeature({})).toThrow( + 'You must provide the entities option', + ); + }); + }); +}); diff --git a/packages/nestjs-cache/src/cache.module.ts b/packages/nestjs-cache/src/cache.module.ts new file mode 100644 index 000000000..e566fde4e --- /dev/null +++ b/packages/nestjs-cache/src/cache.module.ts @@ -0,0 +1,47 @@ +import { DynamicModule, Module } from '@nestjs/common'; + +import { + CacheAsyncOptions, + CacheModuleClass, + CacheOptions, + createCacheExports, + createCacheImports, + createCacheProviders, +} from './cache.module-definition'; + +/** + * Cache Module + */ +@Module({}) +export class CacheModule extends CacheModuleClass { + static register(options: CacheOptions): DynamicModule { + return super.register(options); + } + + static registerAsync(options: CacheAsyncOptions): DynamicModule { + return super.registerAsync(options); + } + + static forRoot(options: CacheOptions): DynamicModule { + return super.register({ ...options, global: true }); + } + + static forRootAsync(options: CacheAsyncOptions): DynamicModule { + return super.registerAsync({ ...options, global: true }); + } + + static forFeature(options: CacheOptions): DynamicModule { + const { entities } = options; + + if (!entities) { + throw new Error('You must provide the entities option'); + } + + return { + module: CacheModule, + imports: createCacheImports({ entities }), + providers: createCacheProviders({ entities, overrides: options }), + exports: createCacheExports(), + }; + } +} diff --git a/packages/nestjs-cache/src/cache.seeder.ts b/packages/nestjs-cache/src/cache.seeder.ts new file mode 100644 index 000000000..cf93f14f3 --- /dev/null +++ b/packages/nestjs-cache/src/cache.seeder.ts @@ -0,0 +1,23 @@ +import { Seeder } from '@concepta/typeorm-seeding'; +import { CacheFactory } from './cache.factory'; + +/** + * Cache seeder + */ +export class CacheSeeder extends Seeder { + /** + * Runner + */ + public async run(): Promise { + // number of caches to create + const createAmount = process.env?.CACHE_MODULE_SEEDER_AMOUNT + ? Number(process.env.CACHE_MODULE_SEEDER_AMOUNT) + : 50; + + // the factory + const cacheFactory = this.factory(CacheFactory); + + // create a bunch + await cacheFactory.createMany(createAmount); + } +} diff --git a/packages/nestjs-cache/src/cache.types.spec.ts b/packages/nestjs-cache/src/cache.types.spec.ts new file mode 100644 index 000000000..804f62071 --- /dev/null +++ b/packages/nestjs-cache/src/cache.types.spec.ts @@ -0,0 +1,10 @@ +import { CacheResource } from './cache.types'; + +describe('Org Types', () => { + describe('OrgResource enum', () => { + it('should match', async () => { + expect(CacheResource.One).toEqual('cache'); + expect(CacheResource.Many).toEqual('cache-list'); + }); + }); +}); diff --git a/packages/nestjs-cache/src/cache.types.ts b/packages/nestjs-cache/src/cache.types.ts new file mode 100644 index 000000000..a89ba67c8 --- /dev/null +++ b/packages/nestjs-cache/src/cache.types.ts @@ -0,0 +1,4 @@ +export enum CacheResource { + 'One' = 'cache', + 'Many' = 'cache-list', +} diff --git a/packages/nestjs-cache/src/config/cache-default.config.ts b/packages/nestjs-cache/src/config/cache-default.config.ts new file mode 100644 index 000000000..5b7e27c1b --- /dev/null +++ b/packages/nestjs-cache/src/config/cache-default.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from '@nestjs/config'; +import { CACHE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../cache.constants'; +import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; + +/** + * Default configuration for Cache module. + */ +export const cacheDefaultConfig = registerAs( + CACHE_MODULE_DEFAULT_SETTINGS_TOKEN, + (): Partial => ({ + expiresIn: process.env.CACHE_EXPIRE_IN ? process.env.CACHE_EXPIRE_IN : '1d', + }), +); diff --git a/packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts b/packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts new file mode 100644 index 000000000..8229c6faf --- /dev/null +++ b/packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts @@ -0,0 +1,172 @@ +import { CacheCreatableInterface } from '@concepta/ts-common'; +import { SeedingSource } from '@concepta/typeorm-seeding'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import assert from 'assert'; +import supertest from 'supertest'; +import { Repository } from 'typeorm'; + +import { CACHE_MODULE_REPOSITORIES_TOKEN } from '../cache.constants'; + +import { CacheFactory } from '../cache.factory'; +import { CacheSeeder } from '../cache.seeder'; + +import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; + +import { UserCacheEntityFixture } from '../__fixtures__/entities/user-cache-entity.fixture'; +import { UserEntityFixture } from '../__fixtures__/entities/user-entity.fixture'; +import { UserCacheFactoryFixture } from '../__fixtures__/factories/user-cache.factory.fixture'; +import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; + +describe('CacheAssignmentController (e2e)', () => { + let app: INestApplication; + let seedingSource: SeedingSource; + let userFactory: UserFactoryFixture; + let userCacheFactory: UserCacheFactoryFixture; + let user: UserEntityFixture; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + seedingSource = new SeedingSource({ + dataSource: app.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + userFactory = new UserFactoryFixture({ seedingSource }); + userCacheFactory = new UserCacheFactoryFixture({ seedingSource }); + + const cacheSeeder = new CacheSeeder({ + factories: [new CacheFactory({ entity: UserCacheEntityFixture })], + }); + + await seedingSource.run.one(cacheSeeder); + + user = await userFactory.create(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + it('GET /cache/user', async () => { + await userCacheFactory + .map((userCache) => { + userCache.assignee = user; + }) + .createMany(2); + + await supertest(app.getHttpServer()) + .get('/cache/user?limit=2') + .expect(200) + .then((res) => { + assert.strictEqual(res.body.length, 2); + }); + }); + + it('GET /cache/user/:id', async () => { + const userCache = await userCacheFactory + .map((userCache) => { + userCache.assignee = user; + }) + .create(); + + await supertest(app.getHttpServer()) + .get( + `/cache/user/${userCache.id}` + `?filter[0]=key||$eq||${userCache.key}`, + ) + .expect(200) + .then((res) => { + assert.strictEqual(res.body.assignee.id, user.id); + }); + }); + + it('GET /cache/user/ with key and type filters', async () => { + const userCache = await userCacheFactory + .map((userCache) => { + userCache.assignee = user; + userCache.key = 'specific-key'; + userCache.type = 'specific-type'; + userCache.data = JSON.stringify({ name: 'John Doe' }); + }) + .create(); + + const url = + `/cache/user/` + + `?filter[0]=key||$eq||${userCache.key}` + + `&filter[1]=type||$eq||${userCache.type}`; + // Assuming your endpoint can filter by key and type + await supertest(app.getHttpServer()) + .get(url) + .expect(200) + .then((res) => { + const response = res.body[0]; + assert.strictEqual(response.assignee.id, user.id); + assert.strictEqual(response.key, userCache.key); + assert.strictEqual(response.type, userCache.type); + assert.strictEqual(response.data, userCache.data); + }); + }); + + it('POST /cache/user', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assignee: { id: user.id }, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.assignee.id).toBe(user.id); + }); + }); + + it.only('POST /cache/user Duplicated', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assignee: { id: user.id }, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.assignee.id).toBe(user.id); + }); + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(500); + }); + + it('DELETE /cache/user/:id', async () => { + const userCache = await userCacheFactory + .map((userCache) => { + userCache.assignee = user; + }) + .create(); + + await supertest(app.getHttpServer()) + .delete(`/cache/user/${userCache.id}`) + .expect(200); + }); +}); diff --git a/packages/nestjs-cache/src/controllers/cache-crud.controller.ts b/packages/nestjs-cache/src/controllers/cache-crud.controller.ts new file mode 100644 index 000000000..f5589e2ff --- /dev/null +++ b/packages/nestjs-cache/src/controllers/cache-crud.controller.ts @@ -0,0 +1,203 @@ +import { + AccessControlCreateOne, + AccessControlDeleteOne, + AccessControlReadMany, + AccessControlReadOne, +} from '@concepta/nestjs-access-control'; +import { + CrudBody, + CrudController, + CrudControllerInterface, + CrudCreateOne, + CrudDeleteOne, + CrudReadMany, + CrudReadOne, + CrudRequest, + CrudRequestInterface +} from '@concepta/nestjs-crud'; +import { + CacheCreatableInterface, + CacheInterface, + CacheUpdatableInterface, +} from '@concepta/ts-common'; +import { ReferenceAssignment } from '@concepta/ts-core'; +import { Inject, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import ms from 'ms'; +import { + CACHE_MODULE_CRUD_SERVICES_TOKEN, + CACHE_MODULE_SETTINGS_TOKEN, +} from '../cache.constants'; +import { CacheResource } from '../cache.types'; +import { CachePaginatedDto } from '../dto/cache-paginated.dto'; +import { CacheUpdateDto } from '../dto/cache-update.dto'; +import { CacheDto } from '../dto/cache.dto'; +import { AssignmentNotFoundException } from '../exceptions/assignment-not-found.exception'; +import { EntityNotFoundException } from '../exceptions/entity-not-found.exception'; +import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; +import { CacheCrudService } from '../services/cache-crud.service'; + +/** + * Cache assignment controller. + */ +@ApiTags('cache') +@CrudController({ + path: 'cache/:assignment', + model: { + type: CacheDto, + paginatedType: CachePaginatedDto, + }, + params: { + id: { field: 'id', type: 'string', primary: true }, + assignment: { + field: 'assignment', + disabled: true, + }, + }, + join: { cache: { eager: true }, assignee: { eager: true } }, +}) +export class CacheCrudController + implements + CrudControllerInterface< + CacheInterface, + CacheCreatableInterface, + CacheUpdatableInterface, + never + > +{ + /** + * Constructor. + * + * @param allCrudServices instances of all crud services + */ + constructor( + @Inject(CACHE_MODULE_SETTINGS_TOKEN) + private settings: CacheSettingsInterface, + @Inject(CACHE_MODULE_CRUD_SERVICES_TOKEN) + private allCrudServices: Record, + ) {} + + /** + * Get many + * + * @param crudRequest the CRUD request object + * @param assignment the assignment + */ + @CrudReadMany() + @AccessControlReadMany(CacheResource.Many) + async getMany( + @CrudRequest() crudRequest: CrudRequestInterface, + @Param('assignment') assignment: ReferenceAssignment, + ) { + return this.getCrudService(assignment).getMany(crudRequest); + } + + /** + * Get one + * + * @param crudRequest the CRUD request object + * @param assignment The cache assignment + */ + @CrudReadOne() + @AccessControlReadOne(CacheResource.One) + async getOne( + @CrudRequest() crudRequest: CrudRequestInterface, + @Param('assignment') assignment: ReferenceAssignment, + ) { + return this.getCrudService(assignment).getOne(crudRequest); + } + + /** + * Create one + * + * @param crudRequest the CRUD request object + * @param cacheCreateDto cache create dto + * @param assignment The cache assignment + */ + @CrudCreateOne() + @AccessControlCreateOne(CacheResource.One) + async createOne( + @CrudRequest() crudRequest: CrudRequestInterface, + @CrudBody() cacheCreateDto: CacheCreatableInterface, + @Param('assignment') assignment: ReferenceAssignment, + ) { + // TODO: how to set expiration date + const expirationDate = this.getExpirationDate( + cacheCreateDto.expiresIn ?? this.settings.expiresIn, + ); + + // call crud service to create + return this.getCrudService(assignment).createOne(crudRequest, { + ...cacheCreateDto, + expirationDate, + }); + } + + /** + * Create one + * + * @param crudRequest the CRUD request object + * @param cacheUpdateDto cache create dto + * @param assignment The cache assignment + */ + @CrudCreateOne() + @AccessControlCreateOne(CacheResource.One) + async updateOne( + @CrudRequest() crudRequest: CrudRequestInterface, + @CrudBody() cacheUpdateDto: CacheUpdateDto, + @Param('assignment') assignment: ReferenceAssignment, + ) { + // call crud service to create + return this.getCrudService(assignment).updateOne( + crudRequest, + cacheUpdateDto, + ); + } + + /** + * Delete one + * + * @param crudRequest the CRUD request object + * @param assignment The cache assignment + */ + @CrudDeleteOne() + @AccessControlDeleteOne(CacheResource.One) + async deleteOne( + @CrudRequest() crudRequest: CrudRequestInterface, + @Param('assignment') assignment: ReferenceAssignment, + ) { + return this.getCrudService(assignment).deleteOne(crudRequest); + } + + /** + * Get the crud service for the given assignment. + * + * @private + * @param assignment The cache assignment + */ + protected getCrudService(assignment: ReferenceAssignment): CacheCrudService { + // have entity key for given assignment? + if (this.settings.assignments[assignment]) { + // yes, set it + const entityKey = this.settings.assignments[assignment].entityKey; + // repo matching assignment was injected? + if (this.allCrudServices[entityKey]) { + // yes, return it + return this.allCrudServices[entityKey]; + } else { + // bad entity key + throw new EntityNotFoundException(entityKey); + } + } else { + // bad assignment + throw new AssignmentNotFoundException(assignment); + } + } + + private getExpirationDate(expiresIn: string) { + const now = new Date(); + + // add time in seconds to now as string format + return new Date(now.getTime() + ms(expiresIn)); + } +} diff --git a/packages/nestjs-cache/src/dto/cache-create.dto.ts b/packages/nestjs-cache/src/dto/cache-create.dto.ts new file mode 100644 index 000000000..ed3818b74 --- /dev/null +++ b/packages/nestjs-cache/src/dto/cache-create.dto.ts @@ -0,0 +1,18 @@ +import { CacheCreatableInterface } from '@concepta/ts-common'; +import { PickType } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { CacheDto } from './cache.dto'; +/** + * Cache Create DTO + */ +@Exclude() +export class CacheCreateDto + extends PickType(CacheDto, [ + 'key', + 'data', + 'type', + 'expiresIn', + 'assignee', + ] as const) + implements CacheCreatableInterface +{} diff --git a/packages/nestjs-cache/src/dto/cache-paginated.dto.ts b/packages/nestjs-cache/src/dto/cache-paginated.dto.ts new file mode 100644 index 000000000..5fc35cb99 --- /dev/null +++ b/packages/nestjs-cache/src/dto/cache-paginated.dto.ts @@ -0,0 +1,19 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { CacheInterface } from '@concepta/ts-common'; +import { ApiProperty } from '@nestjs/swagger'; +import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; +import { CacheDto } from './cache.dto'; +/** + * Org paginated DTO + */ +@Exclude() +export class CachePaginatedDto extends CrudResponsePaginatedDto { + @Expose() + @ApiProperty({ + type: CacheDto, + isArray: true, + description: 'Array of Caches', + }) + @Type(() => CacheDto) + data: CacheDto[] = []; +} diff --git a/packages/nestjs-cache/src/dto/cache-update.dto.ts b/packages/nestjs-cache/src/dto/cache-update.dto.ts new file mode 100644 index 000000000..1fab31859 --- /dev/null +++ b/packages/nestjs-cache/src/dto/cache-update.dto.ts @@ -0,0 +1,11 @@ +import { CacheUpdatableInterface } from '@concepta/ts-common'; +import { PickType } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { CacheDto } from './cache.dto'; +/** + * Cache Create DTO + */ +@Exclude() +export class CacheUpdateDto + extends PickType(CacheDto, ['key', 'type', 'assignee', 'data'] as const) + implements CacheUpdatableInterface {} diff --git a/packages/nestjs-cache/src/dto/cache.dto.ts b/packages/nestjs-cache/src/dto/cache.dto.ts new file mode 100644 index 000000000..a58cca2bf --- /dev/null +++ b/packages/nestjs-cache/src/dto/cache.dto.ts @@ -0,0 +1,56 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { Allow, IsString, ValidateNested } from 'class-validator'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { CacheInterface } from '@concepta/ts-common'; +import { CommonEntityDto, ReferenceIdDto } from '@concepta/nestjs-common'; + +/** + * Cache Create DTO + */ +@Exclude() +export class CacheDto extends CommonEntityDto implements CacheInterface { + /** + * key + */ + @Expose() + @IsString() + key = ''; + + /** + * data + */ + @Expose() + @IsString() + data = ''; + + /** + * type + */ + @Expose() + @IsString() + type = ''; + + /** + * Expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). + * + * Eg: 60, "2 days", "10h", "7d" + */ + @Expose() + @IsString() + expiresIn = ''; + + /** + * Assignee + */ + @Expose() + @Type(() => ReferenceIdDto) + @ValidateNested() + assignee: ReferenceIdInterface = new ReferenceIdDto(); + + /** + * expirationDate + */ + @Allow() + @Type(() => Date) + expirationDate!: Date; +} diff --git a/packages/nestjs-cache/src/entities/cache-postgres.entity.ts b/packages/nestjs-cache/src/entities/cache-postgres.entity.ts new file mode 100644 index 000000000..116575544 --- /dev/null +++ b/packages/nestjs-cache/src/entities/cache-postgres.entity.ts @@ -0,0 +1,30 @@ +import { Column, Index, Unique } from 'typeorm'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { CacheInterface } from '@concepta/ts-common'; +import { CommonPostgresEntity } from '@concepta/typeorm-common'; + +/** + * Cache Postgres Entity + */ +@Index('key_unique_index', ['key', 'type', 'assignee.id'], { unique: true }) +export abstract class CachePostgresEntity + extends CommonPostgresEntity + implements CacheInterface +{ + @Column() + type!: string; + + @Column() + key!: string; + + @Column({ type: 'jsonb', nullable: true }) + data!: string; + + @Column({ type: 'timestamptz' }) + expirationDate!: Date; + + /** + * Should be overwrite by the table it will be assigned to + */ + assignee!: ReferenceIdInterface; +} diff --git a/packages/nestjs-cache/src/entities/cache-sqlite.entity.ts b/packages/nestjs-cache/src/entities/cache-sqlite.entity.ts new file mode 100644 index 000000000..97b487b4a --- /dev/null +++ b/packages/nestjs-cache/src/entities/cache-sqlite.entity.ts @@ -0,0 +1,31 @@ +import { Column, Index } from 'typeorm'; +import { CommonSqliteEntity } from '@concepta/typeorm-common'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { CacheInterface } from '@concepta/ts-common'; + +/** + * Cache Sqlite Entity + */ + +@Index('key_unique_index', ['key', 'type', 'assignee.id'], { unique: true }) +export abstract class CacheSqliteEntity + extends CommonSqliteEntity + implements CacheInterface +{ + @Column() + key!: string; + + @Column() + type!: string; + + @Column({ type: 'text', nullable: true }) + data!: string; + + @Column({ type: 'datetime' }) + expirationDate!: Date; + + /** + * Should be overwrite by the table it will be assigned to + */ + assignee!: ReferenceIdInterface; +} diff --git a/packages/nestjs-cache/src/exceptions/assignment-not-found.exception.spec.ts b/packages/nestjs-cache/src/exceptions/assignment-not-found.exception.spec.ts new file mode 100644 index 000000000..1b7cead4f --- /dev/null +++ b/packages/nestjs-cache/src/exceptions/assignment-not-found.exception.spec.ts @@ -0,0 +1,26 @@ +import { AssignmentNotFoundException } from "./assignment-not-found.exception"; + +describe('AssignmentNotFoundException', () => { + it('should create an instance with default message', () => { + const assignmentName = 'testAssignment'; + const exception = new AssignmentNotFoundException(assignmentName); + + expect(exception).toBeInstanceOf(Error); + expect(exception.message).toBe( + 'Assignment testAssignment was not registered to be used.', + ); + expect(exception.context).toEqual({ assignmentName: 'testAssignment' }); + expect(exception.errorCode).toBe('CACHE_ASSIGNMENT_NOT_FOUND_ERROR'); + }); + + it('should create an instance with custom message', () => { + const assignmentName = 'testAssignment'; + const customMessage = 'Custom message for %s'; + const exception = new AssignmentNotFoundException( + assignmentName, + customMessage, + ); + + expect(exception.message).toBe('Custom message for testAssignment'); + }); +}); diff --git a/packages/nestjs-cache/src/exceptions/assignment-not-found.exception.ts b/packages/nestjs-cache/src/exceptions/assignment-not-found.exception.ts new file mode 100644 index 000000000..fd47a9320 --- /dev/null +++ b/packages/nestjs-cache/src/exceptions/assignment-not-found.exception.ts @@ -0,0 +1,23 @@ +import { format } from 'util'; +import { ExceptionInterface } from '@concepta/ts-core'; + +export class AssignmentNotFoundException + extends Error + implements ExceptionInterface +{ + errorCode = 'CACHE_ASSIGNMENT_NOT_FOUND_ERROR'; + + context: { + assignmentName: string; + }; + + constructor( + assignmentName: string, + message = 'Assignment %s was not registered to be used.', + ) { + super(format(message, assignmentName)); + this.context = { + assignmentName, + }; + } +} diff --git a/packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.spec.ts b/packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.spec.ts new file mode 100644 index 000000000..4ca8d825a --- /dev/null +++ b/packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.spec.ts @@ -0,0 +1,25 @@ +import { CacheTypeNotDefinedException } from './cache-type-not-defined.exception'; + +describe(CacheTypeNotDefinedException.name, () => { + it('should create an instance of CacheTypeNotDefinedException', () => { + const exception = new CacheTypeNotDefinedException('test'); + expect(exception).toBeInstanceOf(CacheTypeNotDefinedException); + }); + + it('should have the correct error code', () => { + const exception = new CacheTypeNotDefinedException('test'); + expect(exception.errorCode).toBe('CACHE_TYPE_NOT_DEFINED_ERROR'); + }); + + it('should have the correct context', () => { + const exception = new CacheTypeNotDefinedException('test'); + expect(exception.context).toEqual({ type: 'test' }); + }); + + it('should have the correct message', () => { + const exception = new CacheTypeNotDefinedException('test'); + expect(exception.message).toBe( + 'Type test was not defined to be used. please check config.', + ); + }); +}); \ No newline at end of file diff --git a/packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.ts b/packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.ts new file mode 100644 index 000000000..c347e9e39 --- /dev/null +++ b/packages/nestjs-cache/src/exceptions/cache-type-not-defined.exception.ts @@ -0,0 +1,23 @@ +import { format } from 'util'; +import { ExceptionInterface } from '@concepta/ts-core'; + +export class CacheTypeNotDefinedException + extends Error + implements ExceptionInterface +{ + errorCode = 'CACHE_TYPE_NOT_DEFINED_ERROR'; + + context: { + type: string; + }; + + constructor( + type: string, + message = 'Type %s was not defined to be used. please check config.', + ) { + super(format(message, type)); + this.context = { + type, + }; + } +} diff --git a/packages/nestjs-cache/src/exceptions/entity-not-found.exception.spec.ts b/packages/nestjs-cache/src/exceptions/entity-not-found.exception.spec.ts new file mode 100644 index 000000000..4465d79f2 --- /dev/null +++ b/packages/nestjs-cache/src/exceptions/entity-not-found.exception.spec.ts @@ -0,0 +1,25 @@ +import { EntityNotFoundException } from './entity-not-found.exception'; + +describe(EntityNotFoundException.name, () => { + it('should create an instance of EntityNotFoundException', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception).toBeInstanceOf(EntityNotFoundException); + }); + + it('should have the correct error message', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception.message).toBe( + 'Entity TestEntity was not registered to be used.', + ); + }); + + it('should have the correct context', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception.context).toEqual({ entityName: 'TestEntity' }); + }); + + it('should have the correct error code', () => { + const exception = new EntityNotFoundException('TestEntity'); + expect(exception.errorCode).toBe('CACHE_ENTITY_NOT_FOUND_ERROR'); + }); +}); diff --git a/packages/nestjs-cache/src/exceptions/entity-not-found.exception.ts b/packages/nestjs-cache/src/exceptions/entity-not-found.exception.ts new file mode 100644 index 000000000..f7e0b05d7 --- /dev/null +++ b/packages/nestjs-cache/src/exceptions/entity-not-found.exception.ts @@ -0,0 +1,23 @@ +import { format } from 'util'; +import { ExceptionInterface } from '@concepta/ts-core'; + +export class EntityNotFoundException + extends Error + implements ExceptionInterface +{ + errorCode = 'CACHE_ENTITY_NOT_FOUND_ERROR'; + + context: { + entityName: string; + }; + + constructor( + entityName: string, + message = 'Entity %s was not registered to be used.', + ) { + super(format(message, entityName)); + this.context = { + entityName, + }; + } +} diff --git a/packages/nestjs-cache/src/index.spec.ts b/packages/nestjs-cache/src/index.spec.ts new file mode 100644 index 000000000..95871f85b --- /dev/null +++ b/packages/nestjs-cache/src/index.spec.ts @@ -0,0 +1,29 @@ +import { + CacheModule, + CacheService, + CachePostgresEntity, + CacheSqliteEntity, + CacheCreateDto, +} from './index'; + +describe('index', () => { + it('should be an instance of Function', () => { + expect(CacheModule).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(CacheService).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(CachePostgresEntity).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(CacheSqliteEntity).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(CacheCreateDto).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-cache/src/index.ts b/packages/nestjs-cache/src/index.ts new file mode 100644 index 000000000..70c3ad86b --- /dev/null +++ b/packages/nestjs-cache/src/index.ts @@ -0,0 +1,9 @@ +export { CacheModule } from './cache.module'; + +export { CachePostgresEntity } from './entities/cache-postgres.entity'; +export { CacheSqliteEntity } from './entities/cache-sqlite.entity'; + +export { CacheService } from './services/cache.service'; +export { CacheCreateDto } from './dto/cache-create.dto'; +export { CacheUpdateDto } from './dto/cache-update.dto'; +export { CacheDto } from './dto/cache.dto'; diff --git a/packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts b/packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts new file mode 100644 index 000000000..bffbcd5ac --- /dev/null +++ b/packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts @@ -0,0 +1,5 @@ +import { TypeOrmExtEntityOptionInterface } from '@concepta/nestjs-typeorm-ext'; + +export interface CacheEntitiesOptionsInterface { + entities: Record; +} diff --git a/packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts b/packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts new file mode 100644 index 000000000..6c3623e3f --- /dev/null +++ b/packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts @@ -0,0 +1,6 @@ +import { DynamicModule } from '@nestjs/common'; +import { CacheEntitiesOptionsInterface } from './cache-entities-options.interface'; + +export interface CacheOptionsExtrasInterface + extends Pick, + Partial {} diff --git a/packages/nestjs-cache/src/interfaces/cache-options.interface.ts b/packages/nestjs-cache/src/interfaces/cache-options.interface.ts new file mode 100644 index 000000000..236ef8e4a --- /dev/null +++ b/packages/nestjs-cache/src/interfaces/cache-options.interface.ts @@ -0,0 +1,5 @@ +import { CacheSettingsInterface } from './cache-settings.interface'; + +export interface CacheOptionsInterface { + settings?: CacheSettingsInterface; +} diff --git a/packages/nestjs-cache/src/interfaces/cache-service.interface.ts b/packages/nestjs-cache/src/interfaces/cache-service.interface.ts new file mode 100644 index 000000000..00eb3407b --- /dev/null +++ b/packages/nestjs-cache/src/interfaces/cache-service.interface.ts @@ -0,0 +1,23 @@ +import { ReferenceAssignment } from '@concepta/ts-core'; +import { QueryOptionsInterface } from '@concepta/typeorm-common'; +import { + CacheClearInterface, + CacheCreateInterface, + CacheDeleteInterface, + CacheGetOneInterface, + CacheInterface, + CacheUpdateInterface, +} from '@concepta/ts-common'; + +export interface CacheServiceInterface + extends CacheCreateInterface, + CacheDeleteInterface, + CacheUpdateInterface, + CacheGetOneInterface, + CacheClearInterface { + getAssignedCaches( + assignment: ReferenceAssignment, + cache: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise; +} diff --git a/packages/nestjs-cache/src/interfaces/cache-settings.interface.ts b/packages/nestjs-cache/src/interfaces/cache-settings.interface.ts new file mode 100644 index 000000000..441789d1c --- /dev/null +++ b/packages/nestjs-cache/src/interfaces/cache-settings.interface.ts @@ -0,0 +1,6 @@ +import { LiteralObject } from '@concepta/ts-core'; + +export interface CacheSettingsInterface { + assignments: LiteralObject<{ entityKey: string }>; + expiresIn?: string | undefined; +} diff --git a/packages/nestjs-cache/src/seeding.ts b/packages/nestjs-cache/src/seeding.ts new file mode 100644 index 000000000..11d83537c --- /dev/null +++ b/packages/nestjs-cache/src/seeding.ts @@ -0,0 +1,7 @@ +/** + * These exports all you to import seeding related classes + * and tools without loading the entire module which + * runs all of it's decorators and meta data. + */ +export { CacheFactory } from './cache.factory'; +export { CacheSeeder } from './cache.seeder'; diff --git a/packages/nestjs-cache/src/services/cache-crud.service.ts b/packages/nestjs-cache/src/services/cache-crud.service.ts new file mode 100644 index 000000000..54603b677 --- /dev/null +++ b/packages/nestjs-cache/src/services/cache-crud.service.ts @@ -0,0 +1,18 @@ +import { TypeOrmCrudService } from '@concepta/nestjs-crud'; +import { CacheInterface } from '@concepta/ts-common'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +/** + * Cache CRUD service + */ +@Injectable() +export class CacheCrudService extends TypeOrmCrudService { + /** + * Constructor + * + * @param repo instance of the cache repository. + */ + constructor(repo: Repository) { + super(repo); + } +} diff --git a/packages/nestjs-cache/src/services/cache.service.spec.ts b/packages/nestjs-cache/src/services/cache.service.spec.ts new file mode 100644 index 000000000..e7a4485db --- /dev/null +++ b/packages/nestjs-cache/src/services/cache.service.spec.ts @@ -0,0 +1,140 @@ +import { CacheCreatableInterface, CacheInterface } from '@concepta/ts-common'; +import { + QueryOptionsInterface, + ReferenceMutateException, + ReferenceValidationException, + RepositoryProxy, +} from '@concepta/typeorm-common'; +import { mock } from 'jest-mock-extended'; +import { CacheService } from './cache.service'; +import { Repository } from 'typeorm'; +import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; + +import { CacheCreateDto } from '../dto/cache-create.dto'; +import { ReferenceAssignment } from '@concepta/ts-core'; + +describe('CacheService', () => { + let service: CacheService; + let repo: Repository; + let settings: CacheSettingsInterface; + const cache: CacheCreatableInterface = { + key: 'testKey', + type: 'testType', + data: 'testData', + assignee: { id: 'testAssignee' }, + expiresIn: '1h', + }; + const queryOptions: QueryOptionsInterface = {}; + const assignment: ReferenceAssignment = 'testAssignment'; + const cacheCreateDto = new CacheCreateDto(); + const repoProxyMock = mock>(); + const expirationDate = new Date(); + + beforeEach(() => { + repo = mock>(); + settings = mock(); + settings.expiresIn = '1h'; + service = new CacheService({ testAssignment: repo }, settings); + + expirationDate.setHours(expirationDate.getHours() + 1); + }); + + describe(CacheService.prototype.create, () => { + it('should create a cache entry', async () => { + Object.assign(cacheCreateDto, cache); + + + repoProxyMock.repository.mockReturnValue(repo); + + // Mocking validateDto method + service['validateDto'] = jest.fn().mockResolvedValue(cacheCreateDto); + + // Mocking getExpirationDate method + service['getExpirationDate'] = jest.fn().mockReturnValue(expirationDate); + + // Mocking RepositoryProxy class + jest.spyOn(RepositoryProxy.prototype, 'repository').mockReturnValue(repo); + + await service.create(assignment, cache, queryOptions); + + expect(repo.save).toHaveBeenCalledWith({ + key: cache.key, + type: cache.type, + data: cache.data, + assignee: cache.assignee, + expirationDate, + }); + }); + + it('should throw a ReferenceValidationException on error', async () => { + const assignment: ReferenceAssignment = 'testAssignment'; + + const error = new ReferenceValidationException('error', []); + service['validateDto'] = jest.fn().mockRejectedValue(error); + + await expect( + service.create(assignment, cache, queryOptions), + ).rejects.toThrow(ReferenceValidationException); + }); + + it('should throw a ReferenceMutateException on error', async () => { + const assignment: ReferenceAssignment = 'testAssignment'; + + const error = new ReferenceValidationException('error', []); + + service['getExpirationDate'] = () => { + throw error; + }; + + const t = async () => + await service.create(assignment, cache, queryOptions); + expect(t).rejects.toThrow(ReferenceMutateException); + }); + }); + + describe(CacheService.prototype.update, () => { + it('should create a cache entry', async () => { + Object.assign(cacheCreateDto, cache); + + repoProxyMock.repository.mockReturnValue(repo); + + service['validateDto'] = jest.fn(); + service['findCache'] = jest.fn(); + const result = { + key: cache.key, + type: cache.type, + data: cache.data, + assignee: cache.assignee, + expirationDate, + }; + service['mergeEntity'] = jest.fn().mockResolvedValue(result); + + jest.spyOn(RepositoryProxy.prototype, 'repository').mockReturnValue(repo); + + await service.update(assignment, cache, queryOptions); + + expect(repo.save).toHaveBeenCalledWith(result); + }); + + it('should throw a ReferenceValidationException on error', async () => { + const assignment: ReferenceAssignment = 'testAssignment'; + + const error = new ReferenceValidationException('error', []); + service['validateDto'] = jest.fn().mockRejectedValue(error); + + await expect( + service.update(assignment, cache, queryOptions), + ).rejects.toThrow(ReferenceValidationException); + }); + + it('should throw a ReferenceMutateException on error', async () => { + const assignment: ReferenceAssignment = 'testAssignment'; + + const error = new Error('error'); + service['mergeEntity'] = jest.fn().mockResolvedValue(error); + + const t = () => service.update(assignment, cache, queryOptions); + await expect(t).rejects.toThrow(ReferenceMutateException); + }); + }); +}); diff --git a/packages/nestjs-cache/src/services/cache.service.ts b/packages/nestjs-cache/src/services/cache.service.ts new file mode 100644 index 000000000..bd94577f6 --- /dev/null +++ b/packages/nestjs-cache/src/services/cache.service.ts @@ -0,0 +1,317 @@ +import { + CacheCreatableInterface, + CacheInterface, + CacheUpdatableInterface, +} from '@concepta/ts-common'; +import { ReferenceAssignment, ReferenceId, Type } from '@concepta/ts-core'; +import { + QueryOptionsInterface, + ReferenceLookupException, + ReferenceMutateException, + ReferenceValidationException, + RepositoryProxy, +} from '@concepta/typeorm-common'; +import { Inject, Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import ms from 'ms'; +import { DeepPartial, Repository } from 'typeorm'; +import { + CACHE_MODULE_REPOSITORIES_TOKEN, + CACHE_MODULE_SETTINGS_TOKEN, +} from '../cache.constants'; +import { CacheCreateDto } from '../dto/cache-create.dto'; +import { CacheUpdateDto } from '../dto/cache-update.dto'; +import { EntityNotFoundException } from '../exceptions/entity-not-found.exception'; +import { CacheServiceInterface } from '../interfaces/cache-service.interface'; +import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; + +@Injectable() +export class CacheService implements CacheServiceInterface { + constructor( + @Inject(CACHE_MODULE_REPOSITORIES_TOKEN) + private allCacheRepos: Record>, + @Inject(CACHE_MODULE_SETTINGS_TOKEN) + protected readonly settings: CacheSettingsInterface, + ) {} + + /** + * Create a cache with a for the given assignee. + * + * @param assignment The cache assignment + * @param cache The data to create + */ + async create( + assignment: ReferenceAssignment, + cache: CacheCreatableInterface, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // validate the data + const dto = await this.validateDto(CacheCreateDto, cache); + + // break out the vars + const { key, type, data, assignee, expiresIn } = dto; + + // try to find the relationship + try { + // generate the expiration date + const expirationDate = this.getExpirationDate( + expiresIn ?? this.settings.expiresIn, + ); + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + // try to save the item + return repoProxy.repository(queryOptions).save({ + key, + type, + data, + assignee, + expirationDate, + }); + } catch (e) { + throw new ReferenceMutateException(assignmentRepo.metadata.targetName, e); + } + } + + async update( + assignment: ReferenceAssignment, + cache: CacheUpdatableInterface, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // validate the data + const dto = await this.validateDto(CacheUpdateDto, cache); + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + // try to update the item + try { + const assignedCache = await this.findCache(repoProxy, dto, queryOptions); + + const mergedEntity = await this.mergeEntity( + repoProxy, + assignedCache, + dto, + queryOptions, + ); + + return repoProxy.repository(queryOptions).save(mergedEntity); + } catch (e) { + throw new ReferenceMutateException(assignmentRepo.metadata.targetName, e); + } + } + + /** + * Delete a cache based on params + * + * @param assignment The cache assignment + * @param cache The cache to delete + */ + async delete( + assignment: ReferenceAssignment, + cache: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get cache from an assigned user for a category + const assignedCache = await this.get(assignment, cache, queryOptions); + + if (assignedCache) { + this.deleteCache(assignment, assignedCache.id, queryOptions); + } + } + + /** + * Get all CACHEs for assignee. + * + * @param assignment The assignment of the check + * @param cache The cache to get assignments + */ + async getAssignedCaches( + assignment: ReferenceAssignment, + cache: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // break out the args + const { assignee } = cache; + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + // try to find the relationships + try { + // make the query + const assignments = await repoProxy.repository(queryOptions).find({ + where: { + assignee: { id: assignee.id }, + }, + relations: ['assignee'], + }); + + // return the caches from assignee + return assignments; + } catch (e) { + throw new ReferenceLookupException(assignmentRepo.metadata.targetName, e); + } + } + + async get( + assignment: ReferenceAssignment, + cache: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + return await this.findCache(repoProxy, cache, queryOptions); + } + + /** + * Clear all caches for a given assignee. + * + * @param assignment The assignment of the repository + * @param cache The cache to clear + */ + async clear( + assignment: ReferenceAssignment, + cache: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get all caches from an assigned user for a category + const assignedCaches = await this.getAssignedCaches( + assignment, + cache, + queryOptions, + ); + + // Map to get ids + const assignedCacheIds = assignedCaches.map( + (assignedCache) => assignedCache.id, + ); + + if (assignedCacheIds.length > 0) + await this.deleteCache(assignment, assignedCacheIds, queryOptions); + } + + /** + * Delete CACHE based on assignment + * + * @private + * @param assignment The assignment to delete id from + * @param id The id or ids to delete + */ + protected async deleteCache( + assignment: ReferenceAssignment, + id: ReferenceId | ReferenceId[], + queryOptions?: QueryOptionsInterface, + ): Promise { + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + try { + await repoProxy.repository(queryOptions).delete(id); + } catch (e) { + throw new ReferenceMutateException(assignmentRepo.metadata.targetName, e); + } + } + + // Should this be on nestjs-common? + protected async validateDto>( + type: Type, + data: T, + ): Promise { + // convert to dto + const dto = plainToInstance(type, data); + + // validate the data + const validationErrors = await validate(dto); + + // any errors? + if (validationErrors.length) { + // yes, throw error + throw new ReferenceValidationException( + this.constructor.name, + validationErrors, + ); + } + + return dto; + } + + protected async findCache( + repoProxy: RepositoryProxy, + cache: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + const { key, type, assignee } = cache; + try { + const cache = await repoProxy.repository(queryOptions).findOne({ + where: { + key, + type, + assignee, + }, + relations: ['assignee'], + }); + if (!cache) throw new Error('Could not find repository'); + return cache; + } catch (e) { + throw new ReferenceLookupException( + repoProxy.repository(queryOptions).metadata.targetName, + e, + ); + } + } + + /** + * Get the assignment repo for the given assignment. + * + * @private + * @param assignment The cache assignment + */ + protected getAssignmentRepo( + assignment: ReferenceAssignment, + ): Repository { + // repo matching assignment was injected? + if (this.allCacheRepos[assignment]) { + // yes, return it + return this.allCacheRepos[assignment]; + } else { + // bad assignment + throw new EntityNotFoundException(assignment); + } + } + + private async mergeEntity( + repoProxy: RepositoryProxy, + assignedCache: CacheInterface, + dto: CacheUpdateDto, + queryOptions?: QueryOptionsInterface, + ): Promise { + return repoProxy.repository(queryOptions).merge(assignedCache, dto); + } + + // TODO: move this to a help function + private getExpirationDate(expiresIn: string) { + const now = new Date(); + + // add time in seconds to now as string format + return new Date(now.getTime() + ms(expiresIn)); + } +} diff --git a/packages/nestjs-cache/tsconfig.json b/packages/nestjs-cache/tsconfig.json new file mode 100644 index 000000000..ef9980950 --- /dev/null +++ b/packages/nestjs-cache/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/ts-common/src/cache/interfaces/cache-clear.interface.ts b/packages/ts-common/src/cache/interfaces/cache-clear.interface.ts new file mode 100644 index 000000000..a4c45f3b4 --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-clear.interface.ts @@ -0,0 +1,21 @@ +import { + ReferenceAssignment, + ReferenceQueryOptionsInterface, +} from '@concepta/ts-core'; +import { CacheInterface } from './cache.interface'; + +export interface CacheClearInterface< + O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, +> { + /** + * Clear all caches for assign in given category. + * + * @param assignment The assignment of the repository + * @param cache The cache to clear + */ + clear( + assignment: ReferenceAssignment, + cache: Pick, + options?: O, + ): Promise; +} diff --git a/packages/ts-common/src/cache/interfaces/cache-creatable.interface.ts b/packages/ts-common/src/cache/interfaces/cache-creatable.interface.ts new file mode 100644 index 000000000..20428af7e --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-creatable.interface.ts @@ -0,0 +1,6 @@ +import { CacheInterface } from './cache.interface'; + +export interface CacheCreatableInterface + extends Pick { + expiresIn: string; +} diff --git a/packages/ts-common/src/cache/interfaces/cache-create.interface.ts b/packages/ts-common/src/cache/interfaces/cache-create.interface.ts new file mode 100644 index 000000000..e4dbb862f --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-create.interface.ts @@ -0,0 +1,23 @@ +import { + ReferenceAssignment, + ReferenceQueryOptionsInterface, +} from '@concepta/ts-core'; + +import { CacheCreatableInterface } from './cache-creatable.interface'; +import { CacheInterface } from './cache.interface'; + +export interface CacheCreateInterface< + O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, +> { + /** + * Create a cache with a for the given assignee. + * + * @param assignment The cache assignment + * @param cache The CACHE to create + */ + create( + assignment: ReferenceAssignment, + cache: CacheCreatableInterface, + options?: O, + ): Promise; +} diff --git a/packages/ts-common/src/cache/interfaces/cache-delete.interface.ts b/packages/ts-common/src/cache/interfaces/cache-delete.interface.ts new file mode 100644 index 000000000..d5f762640 --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-delete.interface.ts @@ -0,0 +1,20 @@ +import { + ReferenceAssignment, + ReferenceQueryOptionsInterface, +} from '@concepta/ts-core'; +import { CacheInterface } from './cache.interface'; + +export interface CacheDeleteInterface< + O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, +> { + /** + * Delete a cache based on params + * @param assignment The cache assignment + * @param cache The dto with unique keys to delete + */ + delete( + assignment: ReferenceAssignment, + cache: Pick, + options?: O, + ): Promise; +} diff --git a/packages/ts-common/src/cache/interfaces/cache-get-one.interface.ts b/packages/ts-common/src/cache/interfaces/cache-get-one.interface.ts new file mode 100644 index 000000000..30e2c3be4 --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-get-one.interface.ts @@ -0,0 +1,20 @@ +import { + ReferenceAssignment, + ReferenceQueryOptionsInterface, +} from '@concepta/ts-core'; +import { CacheInterface } from './cache.interface'; + +export interface CacheGetOneInterface< + O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, +> { + /** + * Get One cache based on params + * @param assignment The cache assignment + * @param cache The dto with unique keys to delete + */ + get( + assignment: ReferenceAssignment, + cache: Pick, + queryOptions?: O, + ): Promise; +} diff --git a/packages/ts-common/src/cache/interfaces/cache-updatable.interface.ts b/packages/ts-common/src/cache/interfaces/cache-updatable.interface.ts new file mode 100644 index 000000000..281c068b7 --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-updatable.interface.ts @@ -0,0 +1,4 @@ +import { CacheInterface } from './cache.interface'; + +export interface CacheUpdatableInterface + extends Pick {} diff --git a/packages/ts-common/src/cache/interfaces/cache-update.interface.ts b/packages/ts-common/src/cache/interfaces/cache-update.interface.ts new file mode 100644 index 000000000..789d44891 --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache-update.interface.ts @@ -0,0 +1,21 @@ +import { + ReferenceAssignment, + ReferenceQueryOptionsInterface, +} from '@concepta/ts-core'; +import { CacheUpdatableInterface } from './cache-updatable.interface'; +import { CacheInterface } from './cache.interface'; + +export interface CacheUpdateInterface< + O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, +> { + /** + * Update a cache based on params + * @param assignment The cache assignment + * @param cache The dto with unique keys to delete + */ + update( + assignment: ReferenceAssignment, + cache: CacheUpdatableInterface, + options?: O, + ): Promise; +} diff --git a/packages/ts-common/src/cache/interfaces/cache.interface.ts b/packages/ts-common/src/cache/interfaces/cache.interface.ts new file mode 100644 index 000000000..4c89a692b --- /dev/null +++ b/packages/ts-common/src/cache/interfaces/cache.interface.ts @@ -0,0 +1,30 @@ +import { + AuditInterface, + ReferenceAssigneeInterface, + ReferenceIdInterface, +} from '@concepta/ts-core'; + +export interface CacheInterface + extends ReferenceIdInterface, + ReferenceAssigneeInterface, + AuditInterface { + /** + * key to be used as reference for the cache data + */ + key: string; + + /** + * Type of the passcode + */ + type: string; + + /** + * data of the cache + */ + data: string; + + /** + * Date it will expire + */ + expirationDate: Date; +} diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index 2d571fb8b..d65ceb3fb 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -41,6 +41,15 @@ export { OtpValidateInterface } from './otp/interfaces/otp-validate.interface'; export { OtpDeleteInterface } from './otp/interfaces/otp-delete.interface'; export { OtpClearInterface } from './otp/interfaces/otp-clear.interface'; +export { CacheInterface } from './cache/interfaces/cache.interface'; +export { CacheCreatableInterface } from './cache/interfaces/cache-creatable.interface'; +export { CacheCreateInterface } from './cache/interfaces/cache-create.interface'; +export { CacheDeleteInterface } from './cache/interfaces/cache-delete.interface'; +export { CacheClearInterface } from './cache/interfaces/cache-clear.interface'; +export { CacheUpdateInterface } from './cache/interfaces/cache-update.interface'; +export { CacheGetOneInterface } from './cache/interfaces/cache-get-one.interface'; +export { CacheUpdatableInterface } from './cache/interfaces/cache-updatable.interface'; + export { InvitationInterface } from './invitation/interfaces/invitation.interface'; export { InvitationAcceptedEventPayloadInterface } from './invitation/interfaces/invitation-accepted-event-payload.interface'; export { InvitationGetUserEventPayloadInterface } from './invitation/interfaces/invitation-get-user-event-payload.interface';