diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ee765..67b9db8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,37 @@ jobs: run: npm ci - name: Run Tests run: npm test + + test-e2e: + name: "Test End-to-End" + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: TodoListClient + steps: + - name: Git checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Setup PostgreSQL + uses: ikalnytskyi/action-setup-postgres@v8 + with: + username: postgres + password: postgres + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "10.0.*" + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + cache-dependency-path: TodoListClient/package-lock.json + - name: Install Dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + - name: Run End-to-End Tests + run: npm run test:e2e diff --git a/README.md b/README.md index dcf41b4..6bab4a8 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,24 @@ When the API runs for the first time, it automatically creates the PostgreSQL da ### 1. Start the Database -The application expects a PostgreSQL instance with the connection details defined in `TodoListAPI/appsettings.json`. The easiest way to start one is using Docker: +The application expects a PostgreSQL instance with the connection details defined in `TodoListAPI/appsettings.json`. You can start it using Docker Compose: ```shell -docker run --name TodoListSampleDb -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=TodoList -p 5432:5432 -d postgres +docker compose up --detach --wait ``` +- This starts a PostgreSQL database container and a **pgAdmin** container (accessible at [http://localhost:5050](http://localhost:5050), preconfigured to automatically connect to the database). +- To stop the containers: + ```shell + docker compose down + ``` +- To stop the containers and remove persistent data (reset the database): + ```shell + docker compose down --volumes + ``` + > [!NOTE] -> The API will automatically create database tables and seed the demo users and todo-items on its first run. If you ever want a fresh database, stop and remove the container. +> The API will automatically create database tables and seed the demo users and todo-items on its first run. ### 2. Start the API @@ -127,6 +137,39 @@ From the `TodoListClient` directory: --- +## Running End-to-End (E2E) Tests + +The repository includes an end-to-end test suite built with [Playwright](https://playwright.dev/) that validates the entire stack against the real PostgreSQL database, ASP.NET Core API server, and Ember.js client app. + +### Running E2E Tests Locally (using Docker) + +1. **Start the database in Docker:** + ```shell + docker compose up --detach --wait + ``` + +2. **From the `TodoListClient` directory, install browsers (one-time setup):** + ```shell + cd TodoListClient + npx playwright install chromium + ``` + +3. **Run the tests:** + ```shell + npm run test:e2e + ``` + +> [!TIP] +> **Automatic Server Management:** If the backend API (`dotnet run`) and frontend client (`npm start`) are already running, Playwright reuses them automatically. If they are not running, Playwright launches them in the background, runs the test suite, and shuts them down upon completion. + +You can also run tests interactively with the Playwright UI runner: + +```shell +npx playwright test --ui +``` + +--- + ## Updating Ember.js The client project uses standard npm packaging and is configured with Ember's modern Vite blueprint (`@ember/app-blueprint`). To upgrade Ember dependencies in the future, use `npx ember-cli-update`: @@ -148,18 +191,3 @@ The client project uses standard npm packaging and is configured with Ember's mo ```shell npm test ``` - ---- - -## Manual Verification Checklist - -When verifying changes or following an upgrade, check that: - -- [ ] Application loads at [http://localhost:4200](http://localhost:4200) and displays the sign-in form. -- [ ] Attempting to log in with invalid credentials displays an `"Authentication failed"` alert. -- [ ] Logging in as `guest` (`Guest1!`) displays the todo list containing `"owned-by-guest"`. -- [ ] Logging in as `john` (`P@ssw0rd!`) displays the todo list containing `"owned-by-john"`. -- [ ] Input validation: Attempting to save a todo-item with fewer than 4 characters displays a validation error. -- [ ] Adding a valid todo-item saves successfully and navigates back to the updated list. -- [ ] Clicking **Logout** invalidates the session and returns to the login screen. -- [ ] Navigating directly to a protected route (e.g. [http://localhost:4200/s/todo-items](http://localhost:4200/s/todo-items)) while logged out redirects to the login screen. diff --git a/TodoListAPI/Program.cs b/TodoListAPI/Program.cs index 59229c8..0e5e132 100644 --- a/TodoListAPI/Program.cs +++ b/TodoListAPI/Program.cs @@ -105,7 +105,7 @@ { app.UseCors(policy => { - policy.WithOrigins("http://localhost:5000").AllowAnyHeader().AllowAnyMethod().AllowCredentials(); + policy.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod().AllowCredentials(); }); } diff --git a/TodoListClient/.gitignore b/TodoListClient/.gitignore index f1e859b..8aacaf5 100644 --- a/TodoListClient/.gitignore +++ b/TodoListClient/.gitignore @@ -19,6 +19,8 @@ /npm-debug.log* /testem.log /yarn-error.log +/test-results/ +/playwright-report/ # ember-try /.node_modules.ember-try/ diff --git a/TodoListClient/README.md b/TodoListClient/README.md index e7ae718..616a57a 100644 --- a/TodoListClient/README.md +++ b/TodoListClient/README.md @@ -42,6 +42,10 @@ From the `TodoListClient` directory: ``` - Run tests interactively in the browser: Start the dev server (`npm start`) and navigate to [http://localhost:4200/tests](http://localhost:4200/tests). +- Run end-to-end tests against the real API (requires PostgreSQL running in Docker): + ```shell + npm run test:e2e + ``` ## Linting & Formatting diff --git a/TodoListClient/e2e/app.spec.js b/TodoListClient/e2e/app.spec.js new file mode 100644 index 0000000..627ef27 --- /dev/null +++ b/TodoListClient/e2e/app.spec.js @@ -0,0 +1,137 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Todo List Application', () => { + test('Application loads at http://localhost:4200 and displays the sign-in form', async ({ + page, + }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('h1')).toHaveText('Please sign in'); + await expect(page.locator('[data-test-username]')).toBeVisible(); + await expect(page.locator('[data-test-password]')).toBeVisible(); + await expect(page.locator('[data-test-submit]')).toBeVisible(); + }); + + test('Attempting to log in with invalid credentials displays an "Authentication failed" alert', async ({ + page, + }) => { + await page.goto('/login'); + await page.locator('[data-test-username]').fill('wrong-user'); + await page.locator('[data-test-password]').fill('wrong-password'); + await page.locator('[data-test-submit]').click(); + + const alertMessage = page.locator('.ember-notify .message'); + await expect(alertMessage).toBeVisible(); + await expect(alertMessage).toHaveText('Authentication failed'); + await expect(page).toHaveURL(/\/login/); + }); + + test('Logging in as guest displays the todo list containing "owned-by-guest"', async ({ + page, + }) => { + await page.goto('/login'); + await page.locator('[data-test-username]').fill('guest'); + await page.locator('[data-test-password]').fill('Guest1!'); + await page.locator('[data-test-submit]').click(); + + await expect(page).toHaveURL(/\/s\/todo-items/); + await expect(page.locator('h1')).toHaveText('Todo Items'); + await expect(page.locator('[data-test-description]')).toContainText([ + 'owned-by-guest', + ]); + await expect(page.locator('tbody')).not.toContainText('owned-by-john'); + }); + + test('Logging in as john displays the todo list containing "owned-by-john"', async ({ + page, + }) => { + await page.goto('/login'); + await page.locator('[data-test-username]').fill('john'); + await page.locator('[data-test-password]').fill('P@ssw0rd!'); + await page.locator('[data-test-submit]').click(); + + await expect(page).toHaveURL(/\/s\/todo-items/); + await expect(page.locator('h1')).toHaveText('Todo Items'); + await expect(page.locator('[data-test-description]')).toContainText([ + 'owned-by-john', + ]); + await expect(page.locator('tbody')).not.toContainText('owned-by-guest'); + }); + + test('Input validation: Attempting to save a todo-item with fewer than 4 characters displays a validation error', async ({ + page, + }) => { + await page.goto('/login'); + await page.locator('[data-test-username]').fill('guest'); + await page.locator('[data-test-password]').fill('Guest1!'); + await page.locator('[data-test-submit]').click(); + await expect(page).toHaveURL(/\/s\/todo-items/); + + await page.locator('[data-test-add]').click(); + await expect(page).toHaveURL(/\/s\/todo-items\/add/); + + // Empty description validation + await page.locator('[data-test-submit]').click(); + let alertMessage = page.locator('.ember-notify .message'); + await expect(alertMessage).toBeVisible(); + await expect(alertMessage).toContainText("Description can't be blank"); + + // Short description (< 4 characters) validation + await page.locator('[data-test-description-input]').fill('abc'); + await page.locator('[data-test-submit]').click(); + alertMessage = page.locator('.ember-notify .message').last(); + await expect(alertMessage).toBeVisible(); + await expect(alertMessage).toContainText( + 'Description is too short (minimum is 4 characters)', + ); + await expect(page).toHaveURL(/\/s\/todo-items\/add/); + }); + + test('Adding a valid todo-item saves successfully and navigates back to the updated list', async ({ + page, + }) => { + await page.goto('/login'); + await page.locator('[data-test-username]').fill('guest'); + await page.locator('[data-test-password]').fill('Guest1!'); + await page.locator('[data-test-submit]').click(); + await expect(page).toHaveURL(/\/s\/todo-items/); + + await page.locator('[data-test-add]').click(); + await expect(page).toHaveURL(/\/s\/todo-items\/add/); + + const newDescription = `valid-todo-item-${Date.now()}`; + await page.locator('[data-test-description-input]').fill(newDescription); + await page.locator('[data-test-submit]').click(); + + await expect(page).toHaveURL(/\/s\/todo-items/); + await expect(page.locator('[data-test-description]')).toContainText([ + newDescription, + ]); + }); + + test('Clicking Logout invalidates the session and returns to the login screen', async ({ + page, + }) => { + await page.goto('/login'); + await page.locator('[data-test-username]').fill('guest'); + await page.locator('[data-test-password]').fill('Guest1!'); + await page.locator('[data-test-submit]').click(); + await expect(page).toHaveURL(/\/s\/todo-items/); + + await page.locator('[data-test-logout]').click(); + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('h1')).toHaveText('Please sign in'); + }); + + test('Navigating directly to a protected route while logged out redirects to the login screen', async ({ + page, + }) => { + await page.goto('/s/todo-items'); + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('h1')).toHaveText('Please sign in'); + + await page.goto('/s/todo-items/add'); + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('h1')).toHaveText('Please sign in'); + }); +}); diff --git a/TodoListClient/package-lock.json b/TodoListClient/package-lock.json index 2d2438a..b2c0755 100644 --- a/TodoListClient/package-lock.json +++ b/TodoListClient/package-lock.json @@ -26,6 +26,7 @@ "@eslint/js": "^9.25.0", "@glimmer/component": "^2.0.0", "@glimmer/tracking": "^1.1.2", + "@playwright/test": "^1.63.0", "@rollup/plugin-babel": "^7.0.0", "@warp-drive/build-config": "^0.0.3", "babel-plugin-ember-template-compilation": "^3.1.0", @@ -4310,6 +4311,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@pnpm/constants": { "version": "1001.3.1", "resolved": "https://registry.npmjs.org/@pnpm/constants/-/constants-1001.3.1.tgz", @@ -14755,6 +14772,35 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", diff --git a/TodoListClient/package.json b/TodoListClient/package.json index 40cd5fa..2ccce58 100644 --- a/TodoListClient/package.json +++ b/TodoListClient/package.json @@ -24,7 +24,8 @@ "lint:js:fix": "eslint . --fix", "postinstall": "patch-package", "start": "vite", - "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\" --prefixColors auto", + "test": "concurrently \"npm:lint\" \"npm:test:ember\" --names \"lint,test:\" --prefixColors auto", + "test:e2e": "playwright test", "test:ember": "vite build --mode development && ember test --path dist" }, "devDependencies": { @@ -44,6 +45,7 @@ "@eslint/js": "^9.25.0", "@glimmer/component": "^2.0.0", "@glimmer/tracking": "^1.1.2", + "@playwright/test": "^1.63.0", "@rollup/plugin-babel": "^7.0.0", "@warp-drive/build-config": "^0.0.3", "babel-plugin-ember-template-compilation": "^3.1.0", diff --git a/TodoListClient/playwright.config.mjs b/TodoListClient/playwright.config.mjs new file mode 100644 index 0000000..42f47e3 --- /dev/null +++ b/TodoListClient/playwright.config.mjs @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:4200", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: [ + { + command: "dotnet run --project ../TodoListAPI", + url: "http://localhost:5000/api/v1/todo-items", + env: { + ASPNETCORE_ENVIRONMENT: "Development", + ASPNETCORE_URLS: "http://localhost:5000", + }, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: "pipe", + stderr: "pipe", + }, + { + command: "npm start", + url: "http://localhost:4200", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: "pipe", + stderr: "pipe", + }, + ], +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a6ed46c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +name: todolist + +# This file starts a PostgreSQL database in a docker container, which is required for running the application and tests locally. +# It also starts pgAdmin (a web-based PostgreSQL management tool) in a second container, which lets you query the database. +# +# Usage: +# docker compose up --detach --wait +# +# To connect to pgAdmin, open http://localhost:5050. It will automatically log in and connect to the database. +# +# To stop: +# docker compose down +# +# To stop and remove persistent data (reset): +# docker compose down --volumes + +services: + postgres-db: + image: postgres:latest + pull_policy: always + container_name: todolist-postgres-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 1s + timeout: 3s + retries: 5 + start_period: 2s + + pgadmin: + image: dpage/pgadmin4:latest + pull_policy: always + container_name: todolist-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: admin@admin.com + PGADMIN_DEFAULT_PASSWORD: postgres + PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' + PGADMIN_DISABLE_POSTFIX: 'true' + PGADMIN_REPLACE_SERVERS_ON_STARTUP: 'True' + ports: + - "5050:80" + depends_on: + - postgres-db + volumes: + - pgadmin-data:/var/lib/pgadmin + healthcheck: + test: ["CMD", "wget", "-O", "-", "http://localhost:80/misc/ping"] + interval: 2s + timeout: 3s + retries: 25 + start_period: 20s + configs: + - source: pgadmin-servers + target: /pgadmin4/servers.json + +volumes: + pgadmin-data: + +configs: + pgadmin-servers: + content: | + { + "Servers": { + "1": { + "Name": "todolist-postgres-database", + "Group": "Servers", + "Host": "postgres-db", + "Port": 5432, + "MaintenanceDB": "postgres", + "Username": "postgres", + "SSLMode": "prefer", + "PasswordExecCommand": "echo 'postgres'" + } + } + }