diff --git a/.gitignore b/.gitignore index 01eb91354..6a707f2f0 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ src/server/db/generated /test-results/ /playwright-report/ /playwright/.cache/ +e2e/.auth certificates diff --git a/README.md b/README.md index 43c523cb0..c9b85ac2a 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,13 @@ If you want to use the same set of custom duotone icons that Start UI is already E2E tests are setup with Playwright. ```sh -pnpm e2e # Run tests in headless mode, this is the command executed in CI -pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution +pnpm e2e # Run tests in headless mode, this is the command executed in CI +pnpm e2e:setup # Setup context to be used across test for more efficient execution +pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution ``` +> [!WARNING] +> The generated e2e context files contain authentication logic. If you make changes to your local database instance, you should re-run `pnpm e2e:setup`. It will be run automatically in a CI context. ## Production ```bash diff --git a/e2e/api-schema.spec.ts b/e2e/api-schema.spec.ts index f56c3e610..d96627d7d 100644 --- a/e2e/api-schema.spec.ts +++ b/e2e/api-schema.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from 'e2e/utils'; test.describe('App Rest API Schema', () => { test(`App API schema is building without error`, async ({ request }) => { diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 4eaf6bb53..257f812b3 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,28 +1,24 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from 'e2e/utils'; import { ADMIN_EMAIL, USER_EMAIL } from 'e2e/utils/constants'; -import { pageUtils } from 'e2e/utils/page-utils'; test.describe('Login flow', () => { test('Login as admin', async ({ page }) => { - const utils = pageUtils(page); - await page.goto('/login'); - await utils.login({ email: ADMIN_EMAIL }); + await page.to('/login'); + await page.login({ email: ADMIN_EMAIL }); await page.waitForURL('/manager'); await expect(page.getByTestId('layout-manager')).toBeVisible(); }); test('Login as user', async ({ page }) => { - const utils = pageUtils(page); - await page.goto('/login'); - await utils.login({ email: USER_EMAIL }); + await page.to('/login'); + await page.login({ email: USER_EMAIL }); await page.waitForURL('/app'); await expect(page.getByTestId('layout-app')).toBeVisible(); }); test('Login with redirect', async ({ page }) => { - const utils = pageUtils(page); - await page.goto('/app'); - await utils.login({ email: ADMIN_EMAIL }); + await page.to('/app'); + await page.login({ email: ADMIN_EMAIL }); await page.waitForURL('/app'); await expect(page.getByTestId('layout-app')).toBeVisible(); }); diff --git a/e2e/setup/auth.setup.ts b/e2e/setup/auth.setup.ts new file mode 100644 index 000000000..1c4454ab6 --- /dev/null +++ b/e2e/setup/auth.setup.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test'; +import { test as setup } from 'e2e/utils'; +import { + ADMIN_EMAIL, + ADMIN_FILE, + USER_EMAIL, + USER_FILE, +} from 'e2e/utils/constants'; + +/** + * @see https://playwright.dev/docs/auth#multiple-signed-in-roles + */ + +setup('authenticate as admin', async ({ page }) => { + await page.to('/login'); + await page.login({ email: ADMIN_EMAIL }); + + await page.waitForURL('/manager'); + await expect(page.getByTestId('layout-manager')).toBeVisible(); + + await page.context().storageState({ path: ADMIN_FILE }); +}); + +setup('authenticate as user', async ({ page }) => { + await page.to('/login'); + await page.login({ email: USER_EMAIL }); + + await page.waitForURL('/app'); + await expect(page.getByTestId('layout-app')).toBeVisible(); + + await page.context().storageState({ path: USER_FILE }); +}); diff --git a/e2e/users.spec.ts b/e2e/users.spec.ts new file mode 100644 index 000000000..f18873bd6 --- /dev/null +++ b/e2e/users.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'e2e/utils'; +import { ADMIN_FILE, USER_FILE } from 'e2e/utils/constants'; +import { randomString } from 'remeda'; + +test.describe('User management as user', () => { + test.use({ storageState: USER_FILE }); + + test('Should not have access', async ({ page }) => { + await page.to('/manager/users'); + + await expect( + page.getByText("You don't have access to this page") + ).toBeVisible(); + }); +}); + +test.describe('User management as manager', () => { + test.use({ storageState: ADMIN_FILE }); + + test.beforeEach(async ({ page }) => { + await page.to('/manager/users'); + }); + + test('Create a user', async ({ page }) => { + await page.getByText('New User').click(); + + const randomId = randomString(8); + const uniqueEmail = `new-user-${randomId}@user.com`; + + // Fill the form + await page.waitForURL('/manager/users/new'); + await page.getByLabel('Name').fill('New user'); + await page.getByLabel('Email').fill(uniqueEmail); + await page.getByText('Create').click(); + + await page.waitForURL('/manager/users'); + await page.getByPlaceholder('Search...').fill('new-user'); + await expect(page.getByText(uniqueEmail)).toBeVisible(); + }); + + test('Edit a user', async ({ page }) => { + await page.getByText('admin@admin.com').click({ + force: true, + }); + + await page.getByRole('link', { name: 'Edit user' }).click(); + + const randomId = randomString(8); + const newAdminName = `Admin ${randomId}`; + await page.getByLabel('Name').fill(newAdminName); + await page.getByText('Save').click(); + + await expect(page.getByText(newAdminName).first()).toBeVisible(); + }); + + test('Delete a user', async ({ page }) => { + await page + .getByText('user', { + exact: true, + }) + .first() + .click({ force: true }); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect( + page.getByText('You are about to permanently delete this user.') + ).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByText('User deleted')).toBeVisible(); + }); +}); diff --git a/e2e/utils/constants.ts b/e2e/utils/constants.ts index b53db5be9..c8becb29d 100644 --- a/e2e/utils/constants.ts +++ b/e2e/utils/constants.ts @@ -1,2 +1,7 @@ +const AUTH_FILE_BASE = 'e2e/.auth'; + +export const USER_FILE = `${AUTH_FILE_BASE}/user.json`; export const USER_EMAIL = 'user@user.com'; + +export const ADMIN_FILE = `${AUTH_FILE_BASE}/admin.json`; export const ADMIN_EMAIL = 'admin@admin.com'; diff --git a/e2e/utils/index.ts b/e2e/utils/index.ts new file mode 100644 index 000000000..572912c2e --- /dev/null +++ b/e2e/utils/index.ts @@ -0,0 +1,9 @@ +import { test as base } from '@playwright/test'; +import { ExtendedPage, pageWithUtils } from 'e2e/utils/page'; + +const test = base.extend({ + page: pageWithUtils, +}); + +export * from '@playwright/test'; +export { test }; diff --git a/e2e/utils/page-utils.ts b/e2e/utils/page-utils.ts deleted file mode 100644 index 01450f39a..000000000 --- a/e2e/utils/page-utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, Page } from '@playwright/test'; - -import { - AUTH_EMAIL_OTP_MOCKED, - AUTH_SIGNUP_ENABLED, -} from '@/features/auth/config'; -import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; - -import locales from '@/locales'; -import { FileRouteTypes } from '@/routeTree.gen'; - -/** - * Utilities constructor - * - * @example - * ```ts - * test.describe('My scope', () => { - * test('My test', async ({ page }) => { - * const utils = pageUtils(page); - * - * // No need too pass page on each util - * await utils.login(...) - * }) - * }) - * ``` - */ -export const pageUtils = (page: Page) => { - return { - /** - * Utility used to authenticate a user on the app - */ - async login(input: { email: string; code?: string }) { - const routeLogin = '/login' satisfies FileRouteTypes['to']; - const routeLoginVerify = '/login/verify' satisfies FileRouteTypes['to']; - await page.waitForURL(`**${routeLogin}**`); - - await expect( - page.getByText( - locales[DEFAULT_LANGUAGE_KEY].auth.pageLoginWithSignUp.title - ) - ).toBeVisible(); - - await page - .getByPlaceholder(locales[DEFAULT_LANGUAGE_KEY].auth.common.email.label) - .fill(input.email); - - await page - .getByRole('button', { - name: locales[DEFAULT_LANGUAGE_KEY].auth[ - AUTH_SIGNUP_ENABLED ? 'pageLoginWithSignUp' : 'pageLogin' - ].loginWithEmail, - }) - .click(); - - await page.waitForURL(`**${routeLoginVerify}**`); - await page - .getByText(locales[DEFAULT_LANGUAGE_KEY].auth.common.otp.label) - .fill(input.code ?? AUTH_EMAIL_OTP_MOCKED); - }, - } as const; -}; diff --git a/e2e/utils/page.ts b/e2e/utils/page.ts new file mode 100644 index 000000000..518168688 --- /dev/null +++ b/e2e/utils/page.ts @@ -0,0 +1,66 @@ +import { expect, Page } from '@playwright/test'; +import { CustomFixture } from 'e2e/utils/types'; + +import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; + +import { + AUTH_EMAIL_OTP_MOCKED, + AUTH_SIGNUP_ENABLED, +} from '@/features/auth/config'; +import locales from '@/locales'; +import { FileRouteTypes } from '@/routeTree.gen'; + +interface PageUtils { + /** + * Utility used to authenticate a user on the app + */ + login: (input: { email: string; code?: string }) => Promise; + + /** + * Override of the `page.goto` method with typed routes from the app + */ + to: ( + url: FileRouteTypes['to'], + options?: Parameters[1] + ) => ReturnType; +} + +export type ExtendedPage = { page: PageUtils }; + +export const pageWithUtils: CustomFixture = async ( + { page }, + apply +) => { + page.login = async function login(input: { email: string; code?: string }) { + const routeLogin = '/login' satisfies FileRouteTypes['to']; + const routeLoginVerify = '/login/verify' satisfies FileRouteTypes['to']; + await page.waitForURL(`**${routeLogin}**`); + + await expect( + page.getByText( + locales[DEFAULT_LANGUAGE_KEY].auth.pageLoginWithSignUp.title + ) + ).toBeVisible(); + + await page + .getByPlaceholder(locales[DEFAULT_LANGUAGE_KEY].auth.common.email.label) + .fill(input.email); + + await page + .getByRole('button', { + name: locales[DEFAULT_LANGUAGE_KEY].auth[ + AUTH_SIGNUP_ENABLED ? 'pageLoginWithSignUp' : 'pageLogin' + ].loginWithEmail, + }) + .click(); + + await page.waitForURL(`**${routeLoginVerify}**`); + await page + .getByText(locales[DEFAULT_LANGUAGE_KEY].auth.common.otp.label) + .fill(input.code ?? AUTH_EMAIL_OTP_MOCKED); + }; + + page.to = page.goto; + + await apply(page); +}; diff --git a/e2e/utils/types.ts b/e2e/utils/types.ts new file mode 100644 index 000000000..0b8de3ab1 --- /dev/null +++ b/e2e/utils/types.ts @@ -0,0 +1,11 @@ +import { + PlaywrightTestArgs, + PlaywrightTestOptions, + TestFixture, +} from '@playwright/test'; +import { ExtendedPage } from 'e2e/utils/page'; + +export type CustomFixture = TestFixture< + TReturn, + PlaywrightTestArgs & PlaywrightTestOptions & ExtendedPage +>; diff --git a/package.json b/package.json index 886c8ce7a..249665e7d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test": "vitest --browser.headless", "test:ci": "vitest run", "test:ui": "vitest", + "e2e:setup": "dotenv -- cross-env playwright test --project=setup", "e2e": "dotenv -- cross-env playwright test", "e2e:ui": "dotenv -- cross-env playwright test --ui", "dk:init": "docker compose up -d", @@ -37,6 +38,7 @@ "dk:stop": "docker compose stop", "dk:clear": "docker compose down --volumes", "db:init": "pnpm db:push && pnpm db:seed", + "db:reset": "pnpm dk:clear && pnpm dk:init && pnpm db:init", "db:push": "prisma db push", "db:ui": "prisma studio", "db:seed": "dotenv -- cross-env node ./run-jiti ./prisma/seed/index.ts" diff --git a/playwright.config.ts b/playwright.config.ts index 3c87d2e5a..ce56a6f77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,18 +32,23 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + // eslint-disable-next-line sonarjs/slow-regex + { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + dependencies: process.env.CI ? ['setup'] : [], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, + dependencies: process.env.CI ? ['setup'] : [], }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, + dependencies: process.env.CI ? ['setup'] : [], }, ], diff --git a/prisma/seed/book.ts b/prisma/seed/book.ts index d3a19e2a4..ae77d73e2 100644 --- a/prisma/seed/book.ts +++ b/prisma/seed/book.ts @@ -43,6 +43,20 @@ export async function createBooks() { await Promise.all( Array.from({ length: Math.max(0, 100 - existingCount) }, async () => { + const author = faker.book.author(); + const title = faker.book.title(); + + // Avoid @unique([title, author]) constraint failure + const book = await db.book.findFirst({ + where: { + author, + title, + }, + }); + if (book) { + return; + } + await db.book.create({ data: { author: faker.book.author(),