From 67856a79f651f2daabdcd83a365c77c609cd6780 Mon Sep 17 00:00:00 2001 From: Eric Cabrel TIOGO Date: Sat, 1 Jun 2024 13:53:13 +0200 Subject: [PATCH] test: write integration tests for authentication --- .github/workflows/build.yml | 2 +- apps/backend/.env.test | 19 ++ apps/backend/.eslintrc.js | 6 +- apps/backend/package.json | 8 +- .../src/features/app/app.controller.spec.ts | 23 -- .../src/features/app/app.integration.spec.ts | 22 ++ .../src/features/app/app.service.spec.ts | 51 +++++ apps/backend/src/features/app/app.service.ts | 4 - .../auth/graphql/auth.integration.spec.ts | 172 +++++++++++++++ apps/backend/src/main.ts | 21 +- apps/backend/src/server.ts | 32 +++ .../backend/src/tests/app.integration.spec.ts | 22 -- apps/backend/src/utils/tests/helpers.ts | 71 +++++++ apps/backend/src/utils/tests/server.ts | 21 ++ package.json | 1 + packages/domain/.gitignore | 1 + packages/domain/env.d.ts | 1 - packages/domain/package.json | 20 +- .../services/folders/folder.service.test.ts | 2 +- .../services/sessions/session.service.test.ts | 2 +- .../services/snippets/snippet.service.test.ts | 2 +- .../src/services/users/user.service.test.ts | 2 +- packages/domain/tests/database.mjs | 50 ++++- packages/domain/tests/helpers.ts | 196 +++++++++--------- yarn.lock | 107 +++++++++- 25 files changed, 659 insertions(+), 199 deletions(-) create mode 100644 apps/backend/.env.test delete mode 100644 apps/backend/src/features/app/app.controller.spec.ts create mode 100644 apps/backend/src/features/app/app.integration.spec.ts create mode 100644 apps/backend/src/features/app/app.service.spec.ts create mode 100644 apps/backend/src/features/auth/graphql/auth.integration.spec.ts create mode 100644 apps/backend/src/server.ts delete mode 100644 apps/backend/src/tests/app.integration.spec.ts create mode 100644 apps/backend/src/utils/tests/helpers.ts create mode 100644 apps/backend/src/utils/tests/server.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02daf328..becd13b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,7 @@ jobs: - name: Run tests run: | yarn prisma migrate dev --schema=packages/domain/prisma/schema.prisma - yarn test + yarn test --runInBand --coverage preview-frontend: runs-on: ubuntu-latest diff --git a/apps/backend/.env.test b/apps/backend/.env.test new file mode 100644 index 00000000..4c91fe26 --- /dev/null +++ b/apps/backend/.env.test @@ -0,0 +1,19 @@ +NODE_ENV=test +APP_VERSION=1.1.0 +HOST=http://localhost +PORT=7509 +INTROSPECTION_ENABLED=false +DATABASE_URL="mysql://root:secret@127.0.0.1:3313/snipcode" +ADMIN_PASSWORD=qwerty +CONVERTKIT_API_KEY=convertkit-api-key +CONVERTKIT_FORM_ID=convertkit-form-id +CONVERTKIT_TAG_ID=convertkit-tag-id +GITHUB_CLIENT_ID=github-client-id +GITHUB_CLIENT_SECRET=github-client-secret +WEB_APP_URL=http://localhost:7500 +WEB_AUTH_SUCCESS_URL=http://localhost:7500/auth/success +WEB_AUTH_ERROR_URL=http://localhost:7500/auth/error +SESSION_LIFETIME=90 +SENTRY_DSN=sentry-dsn +SENTRY_ENABLED=false +SNIPPET_RENDERER_API_URL=http://localhost:3000/dev diff --git a/apps/backend/.eslintrc.js b/apps/backend/.eslintrc.js index 9be5993b..c1711948 100644 --- a/apps/backend/.eslintrc.js +++ b/apps/backend/.eslintrc.js @@ -1,11 +1,7 @@ module.exports = { root: true, extends: '../../.eslintrc.json', - ignorePatterns: [ - 'jest.config.ts', - '.eslintrc.js', - 'graphql.schema.ts' - ], + ignorePatterns: ['jest.config.ts', '.eslintrc.js', 'graphql.schema.ts', 'generated.ts'], parserOptions: { ecmaVersion: 2023, sourceType: 'module', diff --git a/apps/backend/package.json b/apps/backend/package.json index ed277d9f..ef2417c9 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -13,10 +13,10 @@ "gql:gen:types": "ts-node scripts/generate-graphql-types.ts", "lint": "eslint \"{src,scripts}/**/*.ts\" --fix", "prod": "node dist/main", - "test": "jest --watch", - "test:coverage": "jest --coverage", + "test": "yarn workspace @snipcode/domain db:test && dotenv -e .env.test -- jest --watchAll --runInBand", + "test:coverage": "yarn test --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:it": "jest integration.spec.ts" + "test:it": "yarn test integration.spec.ts" }, "dependencies": { "@apollo/server": "4.10.4", @@ -37,8 +37,10 @@ "@nestjs/cli": "10.3.2", "@nestjs/schematics": "10.1.1", "@nestjs/testing": "10.3.8", + "@ngneat/falso": "7.2.0", "@types/express": "4.17.21", "@types/supertest": "6.0.2", + "dotenv-cli": "7.4.2", "source-map-support": "0.5.21", "supertest": "7.0.0", "ts-loader": "9.5.1", diff --git a/apps/backend/src/features/app/app.controller.spec.ts b/apps/backend/src/features/app/app.controller.spec.ts deleted file mode 100644 index e468d256..00000000 --- a/apps/backend/src/features/app/app.controller.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello from Snipcode!"', () => { - expect(appController.helloWorld()).toBe('Hello from Snipcode!'); - }); - }); -}); diff --git a/apps/backend/src/features/app/app.integration.spec.ts b/apps/backend/src/features/app/app.integration.spec.ts new file mode 100644 index 00000000..d38448bb --- /dev/null +++ b/apps/backend/src/features/app/app.integration.spec.ts @@ -0,0 +1,22 @@ +import request from 'supertest'; + +import { TestServer, startTestServer } from '../../utils/tests/server'; + +describe('Application Common Endpoints', () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(); + }); + + afterAll(async () => { + await server.close(); + }); + + test('The entry endpoint returns a Hello world message', async () => { + const response = await request(server.app.getHttpServer()).get('/').expect(200); + + expect(response.body).toEqual({}); + expect(response.text).toBe('Hello from Snipcode!'); + }); +}); diff --git a/apps/backend/src/features/app/app.service.spec.ts b/apps/backend/src/features/app/app.service.spec.ts new file mode 100644 index 00000000..03241601 --- /dev/null +++ b/apps/backend/src/features/app/app.service.spec.ts @@ -0,0 +1,51 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService, RoleService, UserService } from '@snipcode/domain'; +import { mock } from 'jest-mock-extended'; + +import { AppService } from './app.service'; + +const prismaServiceMock = mock(); +const roleServiceMock = mock(); +const userServiceMock = mock(); + +describe('Test App Service', () => { + let appService: AppService; + let roleService: RoleService; + let userService: UserService; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + providers: [ + { provide: PrismaService, useValue: prismaServiceMock }, + ConfigService, + { provide: UserService, useValue: userServiceMock }, + { provide: RoleService, useValue: roleServiceMock }, + AppService, + ], + }).compile(); + + appService = app.get(AppService); + roleService = app.get(RoleService); + userService = app.get(UserService); + }); + + test('load default roles and users on service initialization', async () => { + const role = { + createdAt: new Date(), + description: 'Administrator', + id: '1', + level: 1, + name: 'admin' as const, + updatedAt: new Date(), + }; + + roleServiceMock.findByName.mockResolvedValueOnce(role); + + await expect(appService.onModuleInit()).resolves.not.toThrow(); + + expect(roleService.loadRoles).toHaveBeenCalledTimes(1); + expect(userService.loadAdminUser).toHaveBeenCalledTimes(1); + expect(userService.loadAdminUser).toHaveBeenCalledWith(role, 'qwerty'); + }); +}); diff --git a/apps/backend/src/features/app/app.service.ts b/apps/backend/src/features/app/app.service.ts index fbc2872d..8888b536 100644 --- a/apps/backend/src/features/app/app.service.ts +++ b/apps/backend/src/features/app/app.service.ts @@ -17,10 +17,6 @@ export class AppService implements OnModuleInit { const adminRole = await this.roleService.findByName('admin'); - if (!adminRole) { - throw new Error('[Data Loader]: Role administrator not found'); - } - const adminPassword = this.configService.get('ADMIN_PASSWORD'); await this.userService.loadAdminUser(adminRole, adminPassword); diff --git a/apps/backend/src/features/auth/graphql/auth.integration.spec.ts b/apps/backend/src/features/auth/graphql/auth.integration.spec.ts new file mode 100644 index 00000000..8d7398ee --- /dev/null +++ b/apps/backend/src/features/auth/graphql/auth.integration.spec.ts @@ -0,0 +1,172 @@ +import { PrismaService, RoleService, UserService } from '@snipcode/domain'; +import { isValidUUIDV4 } from '@snipcode/utils'; +import request from 'supertest'; + +import { TestHelper } from '../../../utils/tests/helpers'; +import { TestServer, startTestServer } from '../../../utils/tests/server'; + +const graphqlEndpoint = '/graphql'; + +describe('Test Authentication', () => { + let server: TestServer; + let testHelper: TestHelper; + let prismaService: PrismaService; + let roleService: RoleService; + let userService: UserService; + + beforeAll(async () => { + server = await startTestServer(); + + prismaService = server.app.get(PrismaService); + userService = server.app.get(UserService); + roleService = server.app.get(RoleService); + + testHelper = new TestHelper(prismaService, roleService, userService); + }); + + beforeEach(async () => { + await testHelper.cleanDatabase(); + }); + + afterAll(async () => { + await server.close(); + }); + + test('Register a user', async () => { + const query = ` + mutation SignupUser($input: SignupUserInput!) { + signupUser(input: $input) { + __typename + message + } + } + `; + const variables = { + input: { + email: 'jon.doe@snipcode.dev', + name: 'John Doe', + password: 'password', + }, + }; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query, variables }) + .expect(200); + + expect(response.body.data.signupUser).toMatchObject({ + __typename: 'SignupUserResult', + message: 'Account created successfully!', + }); + }); + + test('Register a user with an existing email address will returns an error', async () => { + const query = ` + mutation SignupUser($input: SignupUserInput!) { + signupUser(input: $input) { + __typename + message + } + } + `; + const variables = { + input: { + email: 'jon.doe@snipcode.dev', + name: 'John Doe', + password: 'password', + }, + }; + + await testHelper.createTestUser({ email: variables.input.email }); + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query, variables }) + .expect(200); + + expect(response.body.errors[0].extensions.code).toEqual('EMAIL_ALREADY_TAKEN'); + expect(response.body.errors[0].message).toEqual('The email address is already taken'); + }); + + test('Returns an error when authenticating with bad credentials', async () => { + const query = ` + mutation LoginUser($email: String!, $password: String!) { + loginUser(email: $email, password: $password) { + token + } + } + `; + const variables = { + email: 'jon.doe@example.com', + password: '123456', + }; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query, variables }) + .expect(200); + + expect(response.body.errors[0].extensions.code).toEqual('LOGIN_FAILED'); + expect(response.body.errors[0].message).toEqual('Invalid email address or password.'); + }); + + test('Returns a token when authenticating with correct credentials', async () => { + const query = ` + mutation LoginUser($email: String!, $password: String!) { + loginUser(email: $email, password: $password) { + token + } + } + `; + + await testHelper.createTestUser({ + email: 'jane.doe@snipcode.dev', + isEnabled: true, + password: 'password', + role: 'user', + }); + + const variables = { + email: 'jane.doe@snipcode.dev', + password: 'password', + }; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query, variables }) + .expect(200); + + expect(response.body.data.loginUser.token).toBeDefined(); + expect(isValidUUIDV4(response.body.data.loginUser.token)).toBe(true); + }); + + test('Returns an error message when trying to authenticate with a disabled account', async () => { + const query = ` + mutation LoginUser($email: String!, $password: String!) { + loginUser(email: $email, password: $password) { + token + } + } + `; + + await testHelper.createTestUser({ + email: 'disabled.user@snipcode.dev', + isEnabled: false, + password: 'password', + role: 'user', + }); + + const variables = { + email: 'disabled.user@snipcode.dev', + password: 'password', + }; + + const response = await request(server.app.getHttpServer()) + .post(graphqlEndpoint) + .send({ query, variables }) + .expect(200); + + expect(response.body.errors[0].extensions.code).toEqual('ACCOUNT_DISABLED'); + expect(response.body.errors[0].message).toEqual('Your account is disabled!'); + }); +}); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 47afef39..8507fd67 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -2,32 +2,17 @@ import './configs/instrument'; import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HttpAdapterHost, NestFactory } from '@nestjs/core'; -import * as Sentry from '@sentry/node'; -import { AppModule } from './app.module'; -import { ApplicationExceptionFilter } from './configs/exception.filter'; +import { setupAppServer } from './server'; import { EnvironmentVariables } from './types/common'; -import { CORS_APOLLO_STUDIO_URL } from './utils/constants'; const bootstrap = async () => { - const app = await NestFactory.create(AppModule); - - const { httpAdapter } = app.get(HttpAdapterHost); + const logger = new Logger('NestApplication'); - Sentry.setupNestErrorHandler(app, new ApplicationExceptionFilter(httpAdapter)); + const app = await setupAppServer({ logger }); const configService = app.get(ConfigService); - app.enableCors({ - credentials: true, - origin: [configService.get('WEB_APP_URL'), CORS_APOLLO_STUDIO_URL], - }); - - app.enableShutdownHooks(); - - const logger = new Logger('NestApplication'); - const port = configService.get('PORT'); const host = configService.get('HOST'); diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts new file mode 100644 index 00000000..98b89281 --- /dev/null +++ b/apps/backend/src/server.ts @@ -0,0 +1,32 @@ +import { INestApplication, LoggerService } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; +import * as Sentry from '@sentry/node'; + +import { AppModule } from './app.module'; +import { EnvironmentVariables } from './configs/environment'; +import { ApplicationExceptionFilter } from './configs/exception.filter'; +import { CORS_APOLLO_STUDIO_URL } from './utils/constants'; + +export const setupAppServer = async ({ + logger: loggerService, +}: { + logger?: LoggerService | false; +}): Promise => { + const app = await NestFactory.create(AppModule, { logger: loggerService }); + + const { httpAdapter } = app.get(HttpAdapterHost); + + Sentry.setupNestErrorHandler(app, new ApplicationExceptionFilter(httpAdapter)); + + const configService = app.get(ConfigService); + + app.enableCors({ + credentials: true, + origin: [configService.get('WEB_APP_URL'), CORS_APOLLO_STUDIO_URL], + }); + + app.enableShutdownHooks(); + + return app; +}; diff --git a/apps/backend/src/tests/app.integration.spec.ts b/apps/backend/src/tests/app.integration.spec.ts deleted file mode 100644 index e486b41f..00000000 --- a/apps/backend/src/tests/app.integration.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import request from 'supertest'; - -import { AppModule } from '../app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); - }); -}); diff --git a/apps/backend/src/utils/tests/helpers.ts b/apps/backend/src/utils/tests/helpers.ts new file mode 100644 index 00000000..55297ff6 --- /dev/null +++ b/apps/backend/src/utils/tests/helpers.ts @@ -0,0 +1,71 @@ +import { randEmail, randFullName, randImg, randNumber, randPassword, randTimeZone, randUserName } from '@ngneat/falso'; +import { + CreateUserInput, + OauthProvider, + PrismaService, + Role, + RoleName, + RoleService, + User, + UserService, +} from '@snipcode/domain'; + +export type CreateUserInputArgs = { + email: string; + isEnabled: boolean; + name: string; + oauthProvider: OauthProvider; + password?: string | null; + pictureUrl: string | null; + role: RoleName; + roleId: string; + timezone: string | null; + username: string | null; +}; +export class TestHelper { + constructor( + private readonly prismaService: PrismaService, + private readonly roleService: RoleService, + private readonly userService: UserService, + ) {} + + async findTestRole(name: RoleName): Promise { + const role = await this.roleService.findByName(name); + + if (!role) { + throw new Error(`Role with the name "${name}" not found!`); + } + + return role; + } + + static createTestUserInput(override: Partial): CreateUserInput { + const input = new CreateUserInput({ + email: randEmail(), + name: randFullName(), + oauthProvider: 'github', + password: randPassword(), + pictureUrl: randImg(), + roleId: 'roleId', + timezone: randTimeZone(), + username: randUserName(), + ...override, + }); + + input.isEnabled = Boolean(override.isEnabled ?? randNumber({ max: 1, min: 0 })); + + return input; + } + + async createTestUser(input: Partial): Promise { + const role = await this.findTestRole(input.role ?? 'user'); + + const createUserInput = TestHelper.createTestUserInput({ ...input, roleId: role.id }); + + return this.userService.create(createUserInput); + } + + async cleanDatabase(): Promise { + await this.prismaService.user.deleteMany(); + } +} diff --git a/apps/backend/src/utils/tests/server.ts b/apps/backend/src/utils/tests/server.ts new file mode 100644 index 00000000..04f7643b --- /dev/null +++ b/apps/backend/src/utils/tests/server.ts @@ -0,0 +1,21 @@ +import { INestApplication } from '@nestjs/common'; + +import { setupAppServer } from '../../server'; + +export type TestServer = { + app: INestApplication; + close: () => Promise; +}; + +export const startTestServer = async (): Promise => { + const app = await setupAppServer({ logger: false }); + + await app.listen(7501); + + return { + app, + close: async () => { + await app.close(); + }, + }; +}; diff --git a/package.json b/package.json index 00216ab9..c0dc8f0f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "husky": "9.0.11", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "jest-mock-extended": "3.0.7", "prettier": "3.2.5", "prisma": "5.11.0", "ts-jest": "29.1.2", diff --git a/packages/domain/.gitignore b/packages/domain/.gitignore index 0c152fa2..6d29c27a 100644 --- a/packages/domain/.gitignore +++ b/packages/domain/.gitignore @@ -1,5 +1,6 @@ node_modules dist .env +.env.local .env.test coverage diff --git a/packages/domain/env.d.ts b/packages/domain/env.d.ts index aff7859e..df521717 100644 --- a/packages/domain/env.d.ts +++ b/packages/domain/env.d.ts @@ -1,7 +1,6 @@ export type EnvironmentVariables = { DATABASE_URL: string; NODE_ENV: string; - TEST_DATABASE_URL: string; }; declare global { diff --git a/packages/domain/package.json b/packages/domain/package.json index bf5dcc6b..a3d65229 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -10,20 +10,22 @@ "build": "tsc --project tsconfig.prod.json", "clean": "rm -rf .turbo dist coverage", "lint": "eslint --fix", + "env": "dotenv -e .env.local", "db:dev:create": "pscale branch create core-db $(git rev-parse --abbrev-ref HEAD)", "db:dev:connect": "pscale connect core-db $(git rev-parse --abbrev-ref HEAD) --port 3311", "db:shadow": "docker run -d --rm -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=snipcode --name snipcode-shadow-db -p 3312:3306 mysql:8.0.34", "db:shadow:stop": "docker kill snipcode-shadow-db && docker container prune -f", "db:deploy:create": "pscale deploy-request create core-db dev", - "db:generate": "prisma generate", - "db:migrate": "prisma migrate dev", - "db:reset": "prisma migrate reset --skip-seed", - "db:reset:seed": "prisma migrate reset", - "db:view": "prisma studio", - "db:format": "prisma format", - "db:seed": "prisma db seed", + "db:generate": "yarn env -- prisma generate", + "db:migrate": "yarn env -- prisma migrate dev", + "db:reset": "yarn env -- prisma migrate reset --skip-seed --force", + "db:reset:seed": "yarn env -- prisma migrate reset", + "db:view": "yarn env -- prisma studio", + "db:format": "yarn env -- prisma format", + "db:seed": "yarn env -- prisma db seed", + "db:test": "zx tests/database.mjs", "db:test:stop": "docker container kill snipcode-test-db && docker container prune -f", - "test": "NODE_ENV=test zx tests/database.mjs && jest" + "test": "NODE_ENV=test yarn db:test && dotenv -e .env.test -- jest" }, "dependencies": { "@bugsnag/cuid": "3.1.1", @@ -38,6 +40,8 @@ "@nestjs/testing": "10.3.8", "@ngneat/falso": "7.2.0", "@types/bcryptjs": "2.4.6", + "dotenv-cli": "7.4.2", + "mysql2": "3.10.0", "nock": "13.5.4", "prisma": "5.14.0", "zx": "8.0.2" diff --git a/packages/domain/src/services/folders/folder.service.test.ts b/packages/domain/src/services/folders/folder.service.test.ts index 0b225056..fa09f411 100644 --- a/packages/domain/src/services/folders/folder.service.test.ts +++ b/packages/domain/src/services/folders/folder.service.test.ts @@ -19,7 +19,7 @@ describe('Test Folder service', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ DomainModule.forRootAsync({ - databaseUrl: process.env.TEST_DATABASE_URL, + databaseUrl: process.env.DATABASE_URL, }), ], providers: [RoleService, FolderService], diff --git a/packages/domain/src/services/sessions/session.service.test.ts b/packages/domain/src/services/sessions/session.service.test.ts index 5dcb1af1..b4cdd4d9 100644 --- a/packages/domain/src/services/sessions/session.service.test.ts +++ b/packages/domain/src/services/sessions/session.service.test.ts @@ -14,7 +14,7 @@ describe('Test Session Service', function () { const module: TestingModule = await Test.createTestingModule({ imports: [ DomainModule.forRootAsync({ - databaseUrl: process.env.TEST_DATABASE_URL, + databaseUrl: process.env.DATABASE_URL, }), ], providers: [SessionService], diff --git a/packages/domain/src/services/snippets/snippet.service.test.ts b/packages/domain/src/services/snippets/snippet.service.test.ts index 688898f9..8408a11f 100644 --- a/packages/domain/src/services/snippets/snippet.service.test.ts +++ b/packages/domain/src/services/snippets/snippet.service.test.ts @@ -17,7 +17,7 @@ describe('Test Snippet service', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ DomainModule.forRootAsync({ - databaseUrl: process.env.TEST_DATABASE_URL, + databaseUrl: process.env.DATABASE_URL, }), ], providers: [RoleService, SnippetService], diff --git a/packages/domain/src/services/users/user.service.test.ts b/packages/domain/src/services/users/user.service.test.ts index ee1cec0f..ad0f0385 100644 --- a/packages/domain/src/services/users/user.service.test.ts +++ b/packages/domain/src/services/users/user.service.test.ts @@ -17,7 +17,7 @@ describe('Test User service', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ DomainModule.forRootAsync({ - databaseUrl: process.env.TEST_DATABASE_URL, + databaseUrl: process.env.DATABASE_URL, }), ], providers: [UserService, RoleService], diff --git a/packages/domain/tests/database.mjs b/packages/domain/tests/database.mjs index 00f9bc2c..506fc9a7 100755 --- a/packages/domain/tests/database.mjs +++ b/packages/domain/tests/database.mjs @@ -1,25 +1,53 @@ #!/usr/bin/env zx import { $, sleep } from 'zx'; +import mysql from 'mysql2/promise'; -const MYSQL_DATABASE = 'snipcode'; const CONTAINER_NAME = 'snipcode-test-db'; +const MYSQL_HOST = '127.0.0.1'; +const MYSQL_PORT = '3313'; +const MYSQL_USER = 'root'; +const MYSQL_PASSWORD = 'secret'; +const MYSQL_DATABASE = 'snipcode'; -if (!process.env.CI) { - console.log('Create the test database if necessary'); +const waitForMysql = async () => { + console.log('Waiting for database availability...'); + while (true) { try { - await $`docker ps | grep ${CONTAINER_NAME}`; + const connection = await mysql.createConnection({ + host: MYSQL_HOST, + port: MYSQL_PORT, + user: MYSQL_USER, + password: MYSQL_PASSWORD, + database: MYSQL_DATABASE, + }); + + await connection.end(); + + console.log('The database is ready!!!'); + break; } catch (error) { - // Container not found, creating a new one. - await $`docker run -d --rm --name ${CONTAINER_NAME} -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=${MYSQL_DATABASE} -p 3313:3306 mysql:8.0.34`; - - await sleep(10000); // Wait for 9 seconds the container to initialize + await sleep(1000); } + } +} + +if (!process.env.CI) { + try { + await $`docker ps | grep ${CONTAINER_NAME}`; + } catch (error) { + console.log('Database container not found, creating...'); + + await $`docker run -d --rm --name ${CONTAINER_NAME} -e MYSQL_ROOT_PASSWORD=${MYSQL_PASSWORD} -e MYSQL_DATABASE=${MYSQL_DATABASE} -p ${MYSQL_PORT}:3306 mysql:8.0.34`; + + await waitForMysql(); + + process.env.DATABASE_URL = `mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}`; - process.env.DATABASE_URL = `mysql://root:secret@127.0.0.1:3313/${MYSQL_DATABASE}`; + console.log('Applying database migrations...'); - // Reset database and apply all migrations - await $`yarn prisma migrate reset --force`; + await $`yarn prisma migrate dev`; + } } diff --git a/packages/domain/tests/helpers.ts b/packages/domain/tests/helpers.ts index f37db248..d0d55ef8 100644 --- a/packages/domain/tests/helpers.ts +++ b/packages/domain/tests/helpers.ts @@ -44,20 +44,6 @@ type CreateTestUserArgs = { export class TestHelper { constructor(private readonly prisma: PrismaService) {} - static generateTestId(): string { - return dbID.generate(); - } - - async findTestRole(name: RoleName): Promise { - const role = await this.prisma.role.findUnique({ where: { name } }); - - if (!role) { - throw new Error(`Role with the name "${name}" not found!`); - } - - return role; - } - static createTestUserInput({ email, isEnabled, @@ -82,38 +68,8 @@ export class TestHelper { return input; } - async createTestUser({ - email, - isEnabled, - oauthProvider, - password, - roleName = 'user', - username, - }: CreateTestUserArgs): Promise { - const role = await this.findTestRole(roleName); - - const createUserInput = TestHelper.createTestUserInput({ - email, - isEnabled, - oauthProvider, - password, - roleId: role.id, - username, - }); - - return this.prisma.user.create({ data: createUserInput.toUser() }); - } - - async deleteTestUsersById(userIds: Array): Promise { - const promises = userIds.map(async (userId) => { - if (!userId) { - return; - } - - await this.prisma.user.delete({ where: { id: userId } }); - }); - - return Promise.all(promises); + static generateTestId(): string { + return dbID.generate(); } static updateTestUserInput(roleId: string): UpdateUserInput { @@ -129,38 +85,6 @@ export class TestHelper { }); } - async deleteTestFoldersById(folderIds: Array): Promise { - for (const folderId of folderIds) { - if (!folderId) { - return; - } - - // eslint-disable-next-line no-await-in-loop - await this.prisma.folder.delete({ where: { id: folderId } }); - } - } - - async createUserWithRootFolder(): Promise<[User, Folder]> { - const user = await this.createTestUser({}); - const rootFolder = await this.prisma.folder.create({ data: new CreateUserRootFolderInput(user.id).toFolder() }); - - return [user, rootFolder]; - } - - async createManyTestFolders({ folderNames, parentId, userId }: CreateManyTestFoldersArgs): Promise { - const promises = folderNames.map((name) => { - const createFolderInput = new CreateFolderInput({ - name, - parentId, - userId, - }); - - return this.prisma.folder.create({ data: createFolderInput.toFolder() }); - }); - - return Promise.all(promises); - } - static createTestFolderInput(args?: { name?: string; parentId?: string; userId?: string }): CreateFolderInput { return new CreateFolderInput({ name: args?.name ?? randWord(), @@ -205,26 +129,6 @@ export class TestHelper { }); } - async deleteTestSnippetsById(snippetIds: Array): Promise { - const promises = snippetIds.map(async (snippetId) => { - if (!snippetId) { - return; - } - - await this.prisma.snippet.delete({ where: { id: snippetId } }); - }); - - return Promise.all(promises); - } - - async createTestSnippet( - args: { folderId?: string; name?: string; userId?: string; visibility?: SnippetVisibility } | undefined, - ): Promise { - const createSnippetInput = TestHelper.createTestSnippetInput(args); - - return this.prisma.snippet.create({ data: createSnippetInput.toSnippet() }); - } - static updateTestSnippetInput( args: { name?: string; snippetId?: string; userId?: string; visibility?: SnippetVisibility } | undefined, ): UpdateSnippetInput { @@ -265,6 +169,102 @@ export class TestHelper { }); } + async findTestRole(name: RoleName): Promise { + const role = await this.prisma.role.findUnique({ where: { name } }); + + if (!role) { + throw new Error(`Role with the name "${name}" not found!`); + } + + return role; + } + + async createTestUser({ + email, + isEnabled, + oauthProvider, + password, + roleName = 'user', + username, + }: CreateTestUserArgs): Promise { + const role = await this.findTestRole(roleName); + + const createUserInput = TestHelper.createTestUserInput({ + email, + isEnabled, + oauthProvider, + password, + roleId: role.id, + username, + }); + + return this.prisma.user.create({ data: createUserInput.toUser() }); + } + + async deleteTestUsersById(userIds: Array): Promise { + const promises = userIds.map(async (userId) => { + if (!userId) { + return; + } + + await this.prisma.user.delete({ where: { id: userId } }); + }); + + return Promise.all(promises); + } + + async deleteTestFoldersById(folderIds: Array): Promise { + for (const folderId of folderIds) { + if (!folderId) { + return; + } + + // eslint-disable-next-line no-await-in-loop + await this.prisma.folder.delete({ where: { id: folderId } }); + } + } + + async createUserWithRootFolder(): Promise<[User, Folder]> { + const user = await this.createTestUser({}); + const rootFolder = await this.prisma.folder.create({ data: new CreateUserRootFolderInput(user.id).toFolder() }); + + return [user, rootFolder]; + } + + async createManyTestFolders({ folderNames, parentId, userId }: CreateManyTestFoldersArgs): Promise { + const promises = folderNames.map((name) => { + const createFolderInput = new CreateFolderInput({ + name, + parentId, + userId, + }); + + return this.prisma.folder.create({ data: createFolderInput.toFolder() }); + }); + + return Promise.all(promises); + } + + async deleteTestSnippetsById(snippetIds: Array): Promise { + const promises = snippetIds.map(async (snippetId) => { + if (!snippetId) { + return; + } + + await this.prisma.snippet.delete({ where: { id: snippetId } }); + }); + + return Promise.all(promises); + } + + async createTestSnippet( + args: { folderId?: string; name?: string; userId?: string; visibility?: SnippetVisibility } | undefined, + ): Promise { + const createSnippetInput = TestHelper.createTestSnippetInput(args); + + return this.prisma.snippet.create({ data: createSnippetInput.toSnippet() }); + } + async createTestSession(args: { userId: string }): Promise { const createSessionInput = TestHelper.createTestSessionInput(args.userId); diff --git a/yarn.lock b/yarn.lock index 52ca3124..f3e7386d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4465,10 +4465,12 @@ __metadata: "@nestjs/platform-express": "npm:10.3.8" "@nestjs/schematics": "npm:10.1.1" "@nestjs/testing": "npm:10.3.8" + "@ngneat/falso": "npm:7.2.0" "@prisma/client": "npm:5.14.0" "@sentry/node": "npm:8.3.0" "@types/express": "npm:4.17.21" "@types/supertest": "npm:6.0.2" + dotenv-cli: "npm:7.4.2" graphql: "npm:16.8.1" reflect-metadata: "npm:0.2.2" rxjs: "npm:7.8.1" @@ -4495,6 +4497,8 @@ __metadata: "@types/bcryptjs": "npm:2.4.6" axios: "npm:1.7.2" bcryptjs: "npm:2.4.3" + dotenv-cli: "npm:7.4.2" + mysql2: "npm:3.10.0" nock: "npm:13.5.4" prisma: "npm:5.14.0" unique-username-generator: "npm:1.3.0" @@ -8028,6 +8032,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10/8ea05321576624b90acfc1ee9208b8d1d04b425cf7573b9b4fa40a2c3ed4d4b0af5190567858f532f677ed2003d4d2b73c8130b34e3c7b8d5e88cdcfbfaa1fe7 + languageName: node + linkType: hard + "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -9645,6 +9656,15 @@ __metadata: languageName: node linkType: hard +"generate-function@npm:^2.3.1": + version: 2.3.1 + resolution: "generate-function@npm:2.3.1" + dependencies: + is-property: "npm:^1.0.2" + checksum: 10/318f85af87c3258d86df4ebbb56b63a2ae52e71bd6cde8d0a79de09450de7422a7047fb1f8d52ccc135564a36cb986d73c63149eed96b7ac57e38acba44f29e2 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -10208,7 +10228,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -10675,6 +10695,13 @@ __metadata: languageName: node linkType: hard +"is-property@npm:^1.0.2": + version: 1.0.2 + resolution: "is-property@npm:1.0.2" + checksum: 10/2f66eacb3d7237ba5c725496672edec656a20b12c80790921988578e6b11c258a062ce1e602f3cd2e3c2e05dd8b6e24e1d59254375207f157424a02ef0abb3d7 + languageName: node + linkType: hard + "is-reference@npm:1.2.1": version: 1.2.1 resolution: "is-reference@npm:1.2.1" @@ -11228,6 +11255,18 @@ __metadata: languageName: node linkType: hard +"jest-mock-extended@npm:3.0.7": + version: 3.0.7 + resolution: "jest-mock-extended@npm:3.0.7" + dependencies: + ts-essentials: "npm:^10.0.0" + peerDependencies: + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 + typescript: ^3.0.0 || ^4.0.0 || ^5.0.0 + checksum: 10/7d5fb9d4ad07dbed9d4f1dd011eb26ca20d9ca4aab3c807749f761220315ef8a6bdf767b1ce1e68ae10405e35ba899c4fcee55cf327deb2d9950910e818f40fa + languageName: node + linkType: hard + "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -12016,6 +12055,13 @@ __metadata: languageName: node linkType: hard +"long@npm:^5.2.1": + version: 5.2.3 + resolution: "long@npm:5.2.3" + checksum: 10/9167ec6947a825b827c30da169a7384eec6c0c9ec2f0b9c74da2e93d81159bbe39fb09c3f13dae9721d4b807ccfa09797a7dd1012f5d478e3e33ca3c78b608e6 + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -12094,6 +12140,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^8.0.0": + version: 8.0.5 + resolution: "lru-cache@npm:8.0.5" + checksum: 10/74153ab136d0c2d735003b8b1c0fa8213c94c2520701dfe8bb31d957f975b3d3665b1ef27ac9a5b9f92c8f581c79008834c0f9bd60c5adf368476f9a95e8fa82 + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0" @@ -12636,6 +12689,22 @@ __metadata: languageName: node linkType: hard +"mysql2@npm:3.10.0": + version: 3.10.0 + resolution: "mysql2@npm:3.10.0" + dependencies: + denque: "npm:^2.1.0" + generate-function: "npm:^2.3.1" + iconv-lite: "npm:^0.6.3" + long: "npm:^5.2.1" + lru-cache: "npm:^8.0.0" + named-placeholders: "npm:^1.1.3" + seq-queue: "npm:^0.0.5" + sqlstring: "npm:^2.3.2" + checksum: 10/81426e2b64b2b193c6652a45caef2aec00ddcc849dfaf7e36750f4b2cc8f4bf235b98b1ed385bb541f3a019bcd4310c57ee1522f609c02e1bb3b8964e95acf8b + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -12647,6 +12716,15 @@ __metadata: languageName: node linkType: hard +"named-placeholders@npm:^1.1.3": + version: 1.1.3 + resolution: "named-placeholders@npm:1.1.3" + dependencies: + lru-cache: "npm:^7.14.1" + checksum: 10/7834adc91e92ae1b9c4413384e3ccd297de5168bb44017ff0536705ddc4db421723bd964607849265feb3f6ded390f84cf138e5925f22f7c13324f87a803dc73 + languageName: node + linkType: hard + "nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -14806,6 +14884,13 @@ __metadata: languageName: node linkType: hard +"seq-queue@npm:^0.0.5": + version: 0.0.5 + resolution: "seq-queue@npm:0.0.5" + checksum: 10/fa302e3b2aaece644532603ae42d675f9b8750e395a98740dd58dc5e02985ce6f0c2b78715b5984d6f6a807893735a14212a70d6ec591e6fba410397269588a0 + languageName: node + linkType: hard + "serialize-javascript@npm:^6.0.1": version: 6.0.2 resolution: "serialize-javascript@npm:6.0.2" @@ -15141,6 +15226,7 @@ __metadata: husky: "npm:9.0.11" jest: "npm:29.7.0" jest-environment-jsdom: "npm:29.7.0" + jest-mock-extended: "npm:3.0.7" prettier: "npm:3.2.5" prisma: "npm:5.11.0" ts-jest: "npm:29.1.2" @@ -15303,6 +15389,13 @@ __metadata: languageName: node linkType: hard +"sqlstring@npm:^2.3.2": + version: 2.3.3 + resolution: "sqlstring@npm:2.3.3" + checksum: 10/4e5a25af2d77a031fe00694034bf9fd822ddc3a483c9383124b120aa6b9ae9ab71e173cd29fba9c653998ebfef9e97be668957839960b9b3dc1afcb45f1ddb64 + languageName: node + linkType: hard + "ssri@npm:^10.0.0": version: 10.0.5 resolution: "ssri@npm:10.0.5" @@ -15972,6 +16065,18 @@ __metadata: languageName: node linkType: hard +"ts-essentials@npm:^10.0.0": + version: 10.0.0 + resolution: "ts-essentials@npm:10.0.0" + peerDependencies: + typescript: ">=4.5.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/cee2ba41a5d78688246550e8e30ce201bce7fbcfb9ccb517460a5823143e1b35937e6764cc146d09a132d7da0f2d758a1d45e47a550b6928b98154d3b5f732d8 + languageName: node + linkType: hard + "ts-interface-checker@npm:^0.1.9": version: 0.1.13 resolution: "ts-interface-checker@npm:0.1.13"