diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000..9205ba81 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,69 @@ +name: e2e Tests + +on: + push: + paths: + - 'e2e/**' + - 'shared/**' + - 'api/**' + - 'client/**' + - '.github/workflows/e2e-tests.yml' + - '/*' # include changes in root + - '!infrastructure/**' # exclude infra folder + - 'package.json' + + workflow_dispatch: + + +jobs: + e2e-tests: + name: e2e tests + runs-on: ubuntu-22.04 + + services: + database: + image: postgres:13 + ports: + - 5432:5432 + env: + POSTGRES_USER: blue-carbon-cost + POSTGRES_PASSWORD: blue-carbon-cost + POSTGRES_DB: blc + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Node setup + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - uses: pnpm/action-setup@v4 + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: node_modules + key: ${{ runner.os }}-node_modules-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node_modules- + + - name: Install dependencies + working-directory: . + run: pnpm install + + - name: Install Chromium browser + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Run e2e tests + working-directory: e2e + run: pnpm test + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 27bdfd87..033fe1e2 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,8 @@ dist /infrastructure/.terraform/ # MacOs -.DS_Store \ No newline at end of file +.DS_Store + +# e2e +/e2e/playwright-report/ +/e2e/test-results/ \ No newline at end of file diff --git a/client/.env.test b/client/.env.test new file mode 100644 index 00000000..5acc95e2 --- /dev/null +++ b/client/.env.test @@ -0,0 +1,3 @@ +NEXTAUTH_URL=http://localhost:$PORT +NEXTAUTH_SECRET=WAzjpS46vFxp17TsRDU3FXo+TF0vrfy6uhCXwGMBUE8= +NEXT_PUBLIC_API_URL=https://dev.blue-carbon-cost-tool.dev-vizzuality.com/api \ No newline at end of file diff --git a/client/package.json b/client/package.json index 9182da03..c6f1ed06 100644 --- a/client/package.json +++ b/client/package.json @@ -24,7 +24,8 @@ "react": "^18", "react-dom": "^18", "tailwind-merge": "2.5.3", - "tailwindcss-animate": "1.0.7" + "tailwindcss-animate": "1.0.7", + "zod": "catalog:" }, "devDependencies": { "@types/node": "catalog:", @@ -38,7 +39,6 @@ "prettier-plugin-tailwindcss": "0.6.8", "react-hook-form": "7.53.0", "tailwindcss": "^3.4.1", - "typescript": "catalog:", - "zod": "catalog:" + "typescript": "catalog:" } } diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..d99c4107 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "e2e", + "private": true, + "dependencies": { + "shared": "workspace:*" + }, + "devDependencies": { + "@playwright/test": "1.44.1", + "@types/lodash": "4.17.4", + "@types/node": "catalog:", + "tsc-alias": "1.8.10", + "typescript": "catalog:" + }, + "scripts": { + "pretest": "tsc && tsc-alias", + "test": "playwright test -c ./dist/e2e", + "test:ui": "pnpm pretest && playwright test --ui -c ./dist/e2e", + "codegen": "pnpm --filter api start:dev & pnpm --filter client dev & playwright codegen localhost:3000" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..984e86b4 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from '@playwright/test'; + +const API_URL = 'http://localhost:4000'; +const APP_URL = 'http://localhost:3000'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm --filter api run build && NODE_ENV=test pnpm --filter api run start:prod', + url: API_URL, + reuseExistingServer: !process.env.CI, + }, + { + command: 'NODE_ENV=test pnpm --filter client run build && NODE_ENV=test pnpm --filter client run start', + url: APP_URL, + reuseExistingServer: !process.env.CI, + }, + ], + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: false, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'list' : 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: APP_URL, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/e2e/tests/auth/auth.spec.ts b/e2e/tests/auth/auth.spec.ts new file mode 100644 index 00000000..1702007e --- /dev/null +++ b/e2e/tests/auth/auth.spec.ts @@ -0,0 +1,62 @@ +import { test, expect, Page } from '@playwright/test'; +import { E2eTestManager } from '@shared/lib/e2e-test-manager'; +import { User } from '@shared/entities/users/user.entity'; + +let testManager: E2eTestManager; +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + testManager = await E2eTestManager.load(page); +}); + +test.beforeEach(async () => { + await testManager.clearDatabase(); +}); + +test.afterEach(async () => { + await testManager.clearDatabase(); +}); + +test.afterAll(async () => { + await testManager.close(); +}); + +// test('an user signs up successfully', async ({ page }) => { +// const user: Pick = { +// email: 'johndoe@test.com', +// password: 'password', +// }; +// +// await page.goto('/auth/signup'); +// +// await page.getByLabel('Email').fill(user.email); +// await page.locator('input[type="password"]').fill(user.password); +// await page.getByRole('checkbox').check(); +// +// await page.getByRole('button', { name: /sign up/i }).click(); +// +// await page.waitForURL('/auth/signin'); +// +// await page.getByLabel('Email').fill(user.email); +// await page.locator('input[type="password"]').fill(user.password); +// +// await page.getByRole('button', { name: /log in/i }).click(); +// +// await page.waitForURL('/profile'); +// await expect(await page.locator('input[type="email"]')).toHaveValue( +// user.email +// ); +// }); + +test('an user signs in successfully', async ({ page }) => { + const user: Pick = { + email: 'jhondoe@test.com', + password: '12345678', + partnerName: 'partner-test' + }; + + await testManager.mocks().createUser(user); + await testManager.login(user as User) + await expect(page.getByText(`Email: ${user.email}`)).toBeVisible(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..78bfa233 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "CommonJS", + "moduleResolution": "Node", + "sourceMap": false, + "outDir": "./dist", + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7d625bc..3b4b7d2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,10 +223,10 @@ importers: version: 0.447.0(react@18.3.1) next: specifier: 14.2.10 - version: 14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: 4.24.8 - version: 4.24.8(next@14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18 version: 18.3.1 @@ -239,6 +239,9 @@ importers: tailwindcss-animate: specifier: 1.0.7 version: 1.0.7(tailwindcss@3.4.10(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) + zod: + specifier: 'catalog:' + version: 3.23.8 devDependencies: '@types/node': specifier: 'catalog:' @@ -276,37 +279,74 @@ importers: typescript: specifier: 'catalog:' version: 5.4.5 - zod: + + e2e: + dependencies: + shared: + specifier: workspace:* + version: link:../shared + devDependencies: + '@playwright/test': + specifier: 1.44.1 + version: 1.44.1 + '@types/lodash': + specifier: 4.17.4 + version: 4.17.4 + '@types/node': specifier: 'catalog:' - version: 3.23.8 + version: 20.14.2 + tsc-alias: + specifier: 1.8.10 + version: 1.8.10 + typescript: + specifier: 'catalog:' + version: 5.4.5 shared: dependencies: - '@nestjs/mapped-types': - specifier: ^2.0.5 - version: 2.0.5(@nestjs/common@10.4.1(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(reflect-metadata@0.2.2) '@ts-rest/core': specifier: ^3.51.0 version: 3.51.0(@types/node@20.14.2)(zod@3.23.8) + bcrypt: + specifier: 5.1.1 + version: 5.1.1 class-transformer: specifier: 'catalog:' version: 0.5.1 - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 20.14.2 + lodash: + specifier: 4.17.21 + version: 4.17.21 pg: specifier: 'catalog:' version: 8.12.0 typeorm: specifier: 'catalog:' version: 0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) - typescript: - specifier: 'catalog:' - version: 5.4.5 zod: specifier: 'catalog:' version: 3.23.8 + devDependencies: + '@nestjs/mapped-types': + specifier: ^2.0.5 + version: 2.0.5(@nestjs/common@10.4.1(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(reflect-metadata@0.2.2) + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + '@types/jsonwebtoken': + specifier: 9.0.7 + version: 9.0.7 + '@types/lodash': + specifier: 4.17.10 + version: 4.17.10 + '@types/node': + specifier: 'catalog:' + version: 20.14.2 + jsonwebtoken: + specifier: 9.0.2 + version: 9.0.2 + typescript: + specifier: 'catalog:' + version: 5.4.5 packages: @@ -996,6 +1036,11 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.44.1': + resolution: {integrity: sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==} + engines: {node: '>=16'} + hasBin: true + '@radix-ui/react-compose-refs@1.1.0': resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -1364,6 +1409,15 @@ packages: '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + '@types/jsonwebtoken@9.0.7': + resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + + '@types/lodash@4.17.10': + resolution: {integrity: sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==} + + '@types/lodash@4.17.4': + resolution: {integrity: sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==} + '@types/lodash@4.17.7': resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} @@ -2042,6 +2096,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + comment-json@4.2.3: resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} engines: {node: '>= 6'} @@ -2633,6 +2691,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3476,6 +3539,10 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mylas@2.1.13: + resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} + engines: {node: '>=12.0.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3796,6 +3863,20 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.44.1: + resolution: {integrity: sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==} + engines: {node: '>=16'} + hasBin: true + + playwright@1.44.1: + resolution: {integrity: sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==} + engines: {node: '>=16'} + hasBin: true + + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -3977,6 +4058,10 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4498,6 +4583,10 @@ packages: '@swc/wasm': optional: true + tsc-alias@1.8.10: + resolution: {integrity: sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw==} + hasBin: true + tsconfig-paths-webpack-plugin@4.1.0: resolution: {integrity: sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==} engines: {node: '>=10.13.0'} @@ -5929,6 +6018,10 @@ snapshots: '@pkgr/core@0.1.1': {} + '@playwright/test@1.44.1': + dependencies: + playwright: 1.44.1 + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.5)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6387,6 +6480,14 @@ snapshots: dependencies: '@types/node': 20.14.2 + '@types/jsonwebtoken@9.0.7': + dependencies: + '@types/node': 20.14.2 + + '@types/lodash@4.17.10': {} + + '@types/lodash@4.17.4': {} + '@types/lodash@4.17.7': {} '@types/methods@1.1.4': {} @@ -7197,6 +7298,8 @@ snapshots: commander@4.1.1: {} + commander@9.5.0: {} + comment-json@4.2.3: dependencies: array-timsort: 1.0.3 @@ -7970,6 +8073,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8993,6 +9099,8 @@ snapshots: mute-stream@1.0.0: {} + mylas@2.1.13: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -9007,13 +9115,13 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.24.8(next@14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.25.7 '@panva/hkdf': 1.2.1 cookie: 0.5.0 jose: 4.15.9 - next: 14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) oauth: 0.9.15 openid-client: 5.7.0 preact: 10.24.2 @@ -9024,7 +9132,7 @@ snapshots: optionalDependencies: nodemailer: 6.9.15 - next@14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.10 '@swc/helpers': 0.5.5 @@ -9045,6 +9153,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 14.2.10 '@next/swc-win32-ia32-msvc': 14.2.10 '@next/swc-win32-x64-msvc': 14.2.10 + '@playwright/test': 1.44.1 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -9305,6 +9414,18 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.44.1: {} + + playwright@1.44.1: + dependencies: + playwright-core: 1.44.1 + optionalDependencies: + fsevents: 2.3.2 + + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + pluralize@8.0.0: {} possible-typed-array-names@1.0.0: {} @@ -9420,6 +9541,8 @@ snapshots: dependencies: side-channel: 1.0.6 + queue-lit@1.5.2: {} + queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -9992,6 +10115,15 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tsc-alias@1.8.10: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + globby: 11.1.0 + mylas: 2.1.13 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + tsconfig-paths-webpack-plugin@4.1.0: dependencies: chalk: 4.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16eeafb2..a3b60ef4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - 'api/**' - 'client/**' - 'shared/**' + - 'e2e/**' - 'data/**' diff --git a/shared/lib/db-entities.ts b/shared/lib/db-entities.ts new file mode 100644 index 00000000..aaa25d42 --- /dev/null +++ b/shared/lib/db-entities.ts @@ -0,0 +1,7 @@ +import { MixedList } from 'typeorm/common/MixedList'; +import { EntitySchema } from 'typeorm/entity-schema/EntitySchema'; +import { User } from "@shared/entities/users/user.entity"; + +export const DB_ENTITIES: MixedList = [ + User, +]; diff --git a/shared/lib/db-helpers.ts b/shared/lib/db-helpers.ts new file mode 100644 index 00000000..1542da94 --- /dev/null +++ b/shared/lib/db-helpers.ts @@ -0,0 +1,54 @@ +import { DataSource, EntityMetadata } from 'typeorm'; +import { difference } from 'lodash'; + +export async function clearTestDataFromDatabase( + dataSource: DataSource, +): Promise { + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const entityTableNames: string[] = dataSource.entityMetadatas + .filter( + (entityMetadata: EntityMetadata) => + entityMetadata.tableType === 'regular' || + entityMetadata.tableType === 'junction', + ) + .map((entityMetadata: EntityMetadata) => entityMetadata.tableName); + + await Promise.all( + entityTableNames.map((entityTableName: string) => + queryRunner.query(`TRUNCATE TABLE "${entityTableName}" CASCADE`), + ), + ); + + entityTableNames.push(dataSource.metadataTableName); + entityTableNames.push( + dataSource.options.migrationsTableName || 'migrations', + ); + entityTableNames.push('spatial_ref_sys'); + + const databaseTableNames: string[] = ( + await dataSource.query( + `SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'`, + ) + ).map((e: Record) => e.table_name); + + // todo: Alex to take a look later + // const tablesToDrop = difference(databaseTableNames, entityTableNames); + // + // await Promise.all( + // tablesToDrop.map((tableToDrop: string) => + // queryRunner.dropTable(tableToDrop), + // ), + // ); + await queryRunner.commitTransaction(); + } catch (err) { + // rollback changes before throwing error + await queryRunner.rollbackTransaction(); + throw err; + } finally { + // release query runner which is manually created + await queryRunner.release(); + } +} diff --git a/shared/lib/e2e-test-manager.ts b/shared/lib/e2e-test-manager.ts new file mode 100644 index 00000000..0ebc3b83 --- /dev/null +++ b/shared/lib/e2e-test-manager.ts @@ -0,0 +1,87 @@ +import { DataSource } from 'typeorm'; +import { User } from "@shared/entities/users/user.entity"; +import { + createUser, +} from '@shared/lib/entity-mocks'; +import { clearTestDataFromDatabase } from '@shared/lib/db-helpers'; +import { DB_ENTITIES } from '@shared/lib/db-entities'; +import { sign } from 'jsonwebtoken'; + +const AppDataSource = new DataSource({ + type: 'postgres', + host: 'localhost', + port: 5432, + username: 'blue-carbon-cost', + password: 'blue-carbon-cost', + database: 'blc', + entities: DB_ENTITIES, +}); + +export class E2eTestManager { + dataSource: DataSource; + page: any; + + constructor(dataSource: DataSource, page?: any) { + this.dataSource = dataSource; + this.page = page; + } + + static async load(page?: any) { + await AppDataSource.initialize(); + + return new E2eTestManager(AppDataSource, page); + } + + async clearDatabase() { + await clearTestDataFromDatabase(this.dataSource); + } + + getDataSource() { + return this.dataSource; + } + + async close() { + if (this.page) { + await this.page.close(); + } + await this.dataSource.destroy(); + } + + async createUser(additionalData?: Partial) { + return createUser(this.dataSource, additionalData); + } + + mocks() { + return { + createUser: (additionalData?: Partial) => + createUser(this.getDataSource(), additionalData), + }; + } + + getPage() { + if (!this.page) throw new Error('Playwright Page is not initialized'); + return this.page; + } + + async login(user?: User) { + if (!user) { + user = await this.mocks().createUser(); + } + await this.page.goto('/auth/signin'); + await this.page.getByLabel('Email').fill(user.email); + await this.page.locator('input[type="password"]').fill(user.password); + await this.page.getByRole('button', { name: /log in/i }).click(); + await this.page.waitForURL('/profile'); + return user; + } + + async logout() { + await this.page.goto('/auth/api/signout'); + await this.page.getByRole('button', { name: 'Sign out' }).click(); + } + + async generateToken(user: User) { + // the secret must match the provided for the api when built for e2e tests + return sign({ id: user.id }, 'mysupersecretfortests'); + } +} diff --git a/shared/lib/entity-mocks.ts b/shared/lib/entity-mocks.ts new file mode 100644 index 00000000..9b2f3db1 --- /dev/null +++ b/shared/lib/entity-mocks.ts @@ -0,0 +1,21 @@ +import { genSalt, hash } from 'bcrypt'; +import { DataSource, DeepPartial } from 'typeorm'; +import { User } from "@shared/entities/users/user.entity"; + + +export const createUser = async ( + dataSource: DataSource, + additionalData?: Partial, +): Promise => { + const salt = await genSalt(); + const usedPassword = additionalData?.password ?? '12345678'; + const defaultData: DeepPartial = { + email: 'test@user.com', + password: await hash(usedPassword, salt), + }; + + const user = { ...defaultData, ...additionalData }; + + await dataSource.getRepository(User).save(user); + return { ...user, password: usedPassword } as User; +}; \ No newline at end of file diff --git a/shared/package.json b/shared/package.json index 3fa41ac6..b308739b 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,16 +1,22 @@ { "name": "shared", "private": true, - "devDependencies": { + "dependencies": { + "@ts-rest/core": "^3.51.0", + "bcrypt": "5.1.1", + "class-transformer": "catalog:", + "lodash": "4.17.21", "pg": "catalog:", "typeorm": "catalog:", - "zod": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:" + "zod": "catalog:" }, - "dependencies": { + "devDependencies": { "@nestjs/mapped-types": "^2.0.5", - "@ts-rest/core": "^3.51.0", - "class-transformer": "catalog:" + "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "9.0.7", + "@types/lodash": "4.17.10", + "@types/node": "catalog:", + "jsonwebtoken": "9.0.2", + "typescript": "catalog:" } }