diff --git a/.env.example b/.env.example index 3bb74b5..99786ae 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,16 @@ # EuchreCamp Environment Configuration # ============================================ -# Copy this file to .env (for local dev) or use -# .env.development / .env.ci for specific environments +# Copy this file to .env or use +# .env.development / .env.production for specific environments # ============================================ # Database Configuration # ============================================ # IMPORTANT: Use the appropriate DATABASE_URL for your environment: # -# - Development (ephemeral, synced from prod): euchre_camp_dev -# - CI/Testing (reset before each run): euchre_camp_ci -# - Production (DO NOT USE FOR TESTS): euchre_camp -# -# The .credentials file in the project root contains -# the actual connection strings - DO NOT commit .credentials +# - Development: euchre_camp_dev (in .env.development) +# - CI/Testing: euchre_camp_ci (set via CI_DATABASE_URL secret) +# - Production: euchre_camp (in .env.production, DO NOT USE FOR TESTS) DATABASE_PROVIDER=postgresql @@ -42,7 +39,6 @@ TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # NODE_ENV=development # BETTER_AUTH_URL=http://localhost:3000 # -# For CI (.env.ci): -# DATABASE_URL from .credentials (euchre_camp_ci) -# NODE_ENV=test -# BETTER_AUTH_URL=http://localhost:3000 \ No newline at end of file +# For CI (set via secrets): +# DATABASE_URL set as CI_DATABASE_URL secret (euchre_camp_ci) +# NODE_ENV=test \ No newline at end of file diff --git a/.gitea/WORKFLOW_ARCHITECTURE.md b/.gitea/WORKFLOW_ARCHITECTURE.md index 3cdaff6..216fca4 100644 --- a/.gitea/WORKFLOW_ARCHITECTURE.md +++ b/.gitea/WORKFLOW_ARCHITECTURE.md @@ -132,11 +132,11 @@ When a PR is merged to `main`: ## Database Configuration for CI -### SQLite for CI Acceptance Tests -- **Why SQLite**: No database server required, perfect for CI environments -- **Usage**: PR workflow runs acceptance tests with SQLite database -- **Configuration**: `DATABASE_PROVIDER=sqlite`, `DATABASE_URL=file:./prisma/ci.db` -- **Benefits**: Fast, isolated, no external dependencies +### PostgreSQL for CI Acceptance Tests +- **Why PostgreSQL**: Matches production database, catches PG-specific issues +- **Usage**: PR workflow runs acceptance tests with PostgreSQL database +- **Configuration**: `CI_DATABASE_URL` secret, set as `DATABASE_URL` env var +- **Benefits**: Production-like environment, consistent with dev and prod ### PostgreSQL for Production - **Usage**: Release workflow runs tests in Docker with PostgreSQL diff --git a/.gitignore b/.gitignore index cb831b1..ac5118c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,7 @@ /playwright/.auth/ /test-results /cookies.txt -.env.test -prisma/ci.db +# .env.test was removed — tests use DATABASE_URL from shell or .env.development # next.js /.next/ diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..cab1ff4 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.14.40" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.40", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.40.tgz", + "integrity": "sha512-A2oBQzTPr4AZMcjUWR4RXVAhn6Rc299RYPPLiKzZ1h0aczHm/nTFdJniVEnfR8XkKLm6JXcWK4W9wo4MJJKoaA==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.40", + "effect": "4.0.0-beta.59", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.40", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.40.tgz", + "integrity": "sha512-e+Av0pNPhoPvQ02DK0Km6sHEXmTlFTuei6C8zV6E3/Iw8jQjTWsW/sssq0kKWnpeUqhdZVxPIqDc5Gvo+n/51A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.59", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz", + "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 8f7703e..7a4a966 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,9 +165,9 @@ npm run db:setup-postgres - **Acceptance tests**: `npm run test:acceptance` - **Specific test**: `npm run test:acceptance -- --grep "test name"` -**CI-style acceptance tests with SQLite:** +**CI-style acceptance tests (uses PostgreSQL, set DATABASE_URL in your shell):** ```bash -DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance +DATABASE_PROVIDER=postgresql DATABASE_URL="your_dev_db_url" npm run test:acceptance ``` ### CI Runner Image diff --git a/README.md b/README.md index df765b7..e31e9da 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,8 @@ npm run test # Run acceptance tests npm run test:acceptance -# Run acceptance tests with SQLite (CI-style) -DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance +# Run acceptance tests (CI-style, set DATABASE_URL in your shell) +DATABASE_PROVIDER=postgresql DATABASE_URL="your_dev_db_url" npm run test:acceptance ``` ### Database Commands @@ -400,8 +400,8 @@ The original attempt to use a pre-built CI runner image with pre-installed depen # Run unit tests (same as CI) npm run test:run -# Run acceptance tests with SQLite -DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance +# Run acceptance tests (set DATABASE_URL in your shell) +DATABASE_PROVIDER=postgresql DATABASE_URL="your_dev_db_url" npm run test:acceptance ``` ## Docker Deployment diff --git a/e2e/account-acceptance-api.test.ts b/e2e/account-acceptance-api.test.ts index c1139ad..2843314 100644 --- a/e2e/account-acceptance-api.test.ts +++ b/e2e/account-acceptance-api.test.ts @@ -10,6 +10,8 @@ import { test, expect } from '@playwright/test'; import { prisma } from '@/lib/prisma'; +const BASE_URL = process.env.CI ? 'https://euchre-ci.notsosm.art' : 'http://localhost:3000'; + // Generate unique test account credentials function getTestCredentials() { const timestamp = Date.now(); @@ -52,14 +54,14 @@ test.describe.serial('Account Lifecycle API Acceptance Test', () => { console.log('Test 1 - testEmail:', testEmail); // Register via API - const response = await request.post('http://localhost:3000/api/auth/sign-up/email', { + const response = await request.post('/api/auth/sign-up/email', { data: { email: testEmail, password: testPassword, name: testName }, headers: { - 'Origin': 'http://localhost:3000' + 'Origin': BASE_URL } }); @@ -96,13 +98,13 @@ test.describe.serial('Account Lifecycle API Acceptance Test', () => { } // Login via API - const response = await request.post('http://localhost:3000/api/auth/sign-in/email', { + const response = await request.post('/api/auth/sign-in/email', { data: { email: testEmail, password: testPassword }, headers: { - 'Origin': 'http://localhost:3000' + 'Origin': BASE_URL } }); diff --git a/e2e/admin-smoke-test.test.ts b/e2e/admin-smoke-test.test.ts new file mode 100644 index 0000000..076e0e3 --- /dev/null +++ b/e2e/admin-smoke-test.test.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test' + +test.describe('Admin Smoke Test', () => { + test.describe('Admin Panel Navigation', () => { + test('should navigate to admin dashboard', async ({ page }) => { + await page.goto('/admin') + await expect(page.locator('text=Admin')).toBeVisible() + }) + + test('should navigate to matches admin page', async ({ page }) => { + await page.goto('/admin/matches') + await expect(page.locator('text=Match Management')).toBeVisible() + }) + + test('should navigate to players admin page', async ({ page }) => { + await page.goto('/admin/players') + await expect(page.locator('text=Player Management')).toBeVisible() + }) + + test('should navigate to users admin page', async ({ page }) => { + await page.goto('/admin/users') + await expect(page.locator('text=User Management')).toBeVisible() + }) + }) + + test.describe('Match Management', () => { + test('should display matches page', async ({ page }) => { + await page.goto('/admin/matches') + await expect(page.locator('text=Match Management')).toBeVisible() + + const hasTable = await page.locator('table').count().then(c => c > 0) + const hasEmptyState = await page.locator('text=/no matches|No matches/').count().then(c => c > 0) + + expect(hasTable || hasEmptyState).toBeTruthy() + }) + + test('should have delete button for matches when matches exist', async ({ page }) => { + await page.goto('/admin/matches') + const deleteButtons = page.locator('button:has-text("Delete")') + const count = await deleteButtons.count() + if (count > 0) { + await expect(page.locator('text=Actions')).toBeVisible() + } + }) + }) + + test.describe('Player Management', () => { + test('should display players table', async ({ page }) => { + await page.goto('/admin/players') + await expect(page.locator('table')).toBeVisible() + await expect(page.locator('text=Player Name')).toBeVisible() + await expect(page.locator('text=Current Elo')).toBeVisible() + await expect(page.locator('text=Actions')).toBeVisible() + }) + + test('should have edit and delete buttons for players', async ({ page }) => { + await page.goto('/admin/players') + await expect(page.locator('text=Actions')).toBeVisible() + }) + + test('should allow editing player name', async ({ page }) => { + await page.goto('/admin/players') + const editButton = page.locator('button:has-text("Edit")').first() + if (await editButton.isVisible()) { + await editButton.click() + + await expect(page.locator('text=Edit Player Name')).toBeVisible() + + await page.click('text=Cancel') + await expect(page.locator('text=Edit Player Name')).not.toBeVisible() + } + }) + }) + + test.describe('User Management', () => { + test('should display users page', async ({ page }) => { + await page.goto('/admin/users') + const hasTable = await page.locator('table').isVisible().catch(() => false) + const hasNoUsers = await page.locator('text=No users found').isVisible().catch(() => false) + + expect(hasTable || hasNoUsers).toBeTruthy() + + await expect(page.locator('text=User Management')).toBeVisible() + }) + + test('should have create user link', async ({ page }) => { + await page.goto('/admin/users') + const createLink = page.locator('a.bg-green-600:has-text("Create User")') + await expect(createLink).toBeVisible() + }) + }) +}) diff --git a/e2e/csv-upload-deduplication.test.ts b/e2e/csv-upload-deduplication.test.ts index 5d27d13..8210985 100644 --- a/e2e/csv-upload-deduplication.test.ts +++ b/e2e/csv-upload-deduplication.test.ts @@ -78,7 +78,7 @@ test.describe('CSV Upload Player Deduplication', () => { formData.append('csvFile', file); formData.append('eventId', testTournamentId.toString()); - const response = await request.post('http://localhost:3000/api/matches/upload', { + const response = await request.post('/api/matches/upload', { multipart: formData, }); @@ -132,7 +132,7 @@ test.describe('CSV Upload Player Deduplication', () => { formData.append('csvFile', file); formData.append('eventId', testTournamentId.toString()); - const response = await request.post('http://localhost:3000/api/matches/upload', { + const response = await request.post('/api/matches/upload', { multipart: formData, }); @@ -189,7 +189,7 @@ test.describe('CSV Upload Player Deduplication', () => { formData.append('csvFile', file); formData.append('eventId', testTournamentId.toString()); - const response = await request.post('http://localhost:3000/api/matches/upload', { + const response = await request.post('/api/matches/upload', { multipart: formData, }); diff --git a/e2e/cucumber-e2e.test.ts b/e2e/cucumber-e2e.test.ts index 4eeba46..55cfbc9 100644 --- a/e2e/cucumber-e2e.test.ts +++ b/e2e/cucumber-e2e.test.ts @@ -1,45 +1,25 @@ -/** - * Bridge file to run Cucumber tests through Playwright's test runner - * - * This allows Cucumber tests to benefit from Playwright's: - * - Dev server management - * - Browser lifecycle management - * - Test reporting - * - CI/CD integration - */ - import { test } from '@playwright/test'; import { execSync } from 'child_process'; -// This test file doesn't contain actual tests -// It just runs Cucumber CLI which executes the feature files test.describe('Cucumber E2E Tests', () => { test('Run all Cucumber feature files', async () => { - // This test is a placeholder that triggers Cucumber execution - // In practice, Cucumber should be run directly via CLI - console.log('Cucumber tests should be run via: bun cucumber-js'); + const baseURL = process.env.CI + ? 'https://euchre-ci.notsosm.art' + : 'http://localhost:3000'; + + const result = execSync( + 'npx cucumber-js --config e2e/cucumber/cucumber.config.ts', + { + encoding: 'utf-8', + stdio: 'pipe', + env: { + ...process.env, + BASE_URL: baseURL, + }, + cwd: process.cwd(), + } + ); + + console.log(result); }); }); - -/** - * Alternative approach: Programmatic execution - * - * If you want to run Cucumber programmatically from within Playwright: - */ -/* -import { execSync } from 'child_process'; - -export default async function runCucumberTests() { - try { - const output = execSync( - 'bun cucumber-js --config e2e/cucumber/cucumber.config.ts', - { encoding: 'utf-8', stdio: 'inherit' } - ); - console.log(output); - return true; - } catch (error) { - console.error('Cucumber tests failed:', error); - return false; - } -} -*/ diff --git a/e2e/cucumber/features/home-page.feature b/e2e/cucumber/features/home-page.feature index da902cc..e83b907 100644 --- a/e2e/cucumber/features/home-page.feature +++ b/e2e/cucumber/features/home-page.feature @@ -3,6 +3,11 @@ Feature: Home Page I want to see the home page So that I can learn about the club and view player rankings + Background: + Given there are top players in the system + And there is a club president + And there is a recent tournament + @happy-path @public @home Scenario: Home page displays Top 10 Players Given I am on the home page diff --git a/e2e/cucumber/step-definitions/auth-steps.ts b/e2e/cucumber/step-definitions/auth-steps.ts index 23454b4..d02f360 100644 --- a/e2e/cucumber/step-definitions/auth-steps.ts +++ b/e2e/cucumber/step-definitions/auth-steps.ts @@ -127,7 +127,7 @@ Given('I am logged in as a tournament admin', async function () { // Wait for any redirect away from register page await world.page.waitForURL((url) => !url.toString().includes('/auth/register'), { timeout: 15000 }); - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForTimeout(1000); const currentUrl = world.page.url(); @@ -166,7 +166,7 @@ Given('I am logged in as a tournament admin', async function () { // Navigate to trigger a fresh role fetch await world.page.goto(`${world.baseURL}/rankings`); - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForTimeout(500); } else { console.log(`🌍 WARNING: User not found in DB by email. Trying to find latest user...`); @@ -235,7 +235,7 @@ Given('I am logged in as a site admin', async function () { // Navigate to home page to trigger Navigation re-mount with new role await world.page.goto(`${world.baseURL}/`); - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForTimeout(1000); } } @@ -245,35 +245,53 @@ Given('I am logged in as a site admin', async function () { /** * Precondition: I am logged in as a club admin - * Uses a pre-existing admin user from the database + * Creates a new user via UI and assigns club_admin role via Prisma */ Given('I am logged in as a club admin', async function () { - console.log('🌍 Logging in as existing club admin...'); + console.log('🌍 Creating and logging in as a club admin...'); - // Use the admin user created by seed.js - const adminEmail = 'david@dhg.lol'; - const adminPassword = 'adminadmin'; + const credentials = generateTestCredentials(); + world.user = credentials; - world.user = { - email: adminEmail, - password: adminPassword, - name: 'David Admin', - }; - - await world.page.goto(`${world.baseURL}/auth/login`); + await world.page.goto(`${world.baseURL}/auth/register`); await world.page.waitForLoadState('domcontentloaded'); - await world.page.fill('input[name="email"]', adminEmail); - await world.page.fill('input[name="password"]', adminPassword); - await world.page.click('button[type="submit"]'); + await world.page.fill('input[name="name"]', credentials.name); + await world.page.fill('input[name="email"]', credentials.email); + await world.page.fill('input[name="password"]', credentials.password); - // Wait for redirect after login - try { - await world.page.waitForURL((url) => !url.toString().includes('/auth/login'), { timeout: 10000 }); - console.log(`🌍 Club admin logged in: ${adminEmail}`); - } catch (e) { - console.log('🌍 Login redirect timed out, current URL:', world.page.url()); + await world.page.click('button[type="submit"]'); + await world.page.waitForURL(/\/players\/\d+\/profile/, { timeout: 15000 }); + + const currentUrl = world.page.url(); + const match = currentUrl.match(/\/players\/(\d+)\/profile/); + if (match) { + const playerId = match[1]; + world.playerId = playerId; + + const prisma = await world.getPrisma(); + const player = await prisma.player.findUnique({ + where: { id: parseInt(playerId) }, + include: { user: true } + }); + + if (player && player.user) { + const userId = player.user.id; + (world.user as any).id = userId; + + await prisma.user.update({ + where: { id: userId }, + data: { role: 'club_admin' } + }); + console.log(`🌍 Assigned club_admin role to user: ${userId}`); + + await world.page.goto(`${world.baseURL}/`); + await world.page.waitForLoadState('domcontentloaded'); + await world.page.waitForTimeout(1000); + } } + + console.log(`🌍 Club admin created: ${credentials.email}`); }); /** @@ -566,7 +584,7 @@ When('I go to the tournament schedule page', async function () { const tournamentId = world.tournament?.id || 1; const url = `${world.baseURL}/admin/tournaments/${tournamentId}/schedule?t=${Date.now()}`; await world.page.goto(url); - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); // Wait for ScheduleDisplay client component to hydrate await world.page.waitForTimeout(2000); }); diff --git a/e2e/cucumber/step-definitions/common-steps.ts b/e2e/cucumber/step-definitions/common-steps.ts index 2a5d6fa..daf2f75 100644 --- a/e2e/cucumber/step-definitions/common-steps.ts +++ b/e2e/cucumber/step-definitions/common-steps.ts @@ -110,7 +110,7 @@ When('I go back', async function () { When('I refresh the page', async function () { console.log('🌍 About to refresh page from URL:', world.page.url()); - await world.page.reload({ waitUntil: 'networkidle' }); + await world.page.reload({ waitUntil: 'load' }); console.log('🌍 Page refreshed, new URL:', world.page.url()); // Wait extra time for full render await world.page.waitForTimeout(2000); @@ -191,7 +191,7 @@ When('I click the {string} link', async function (linkText: string) { // Wait for navigation to complete try { - await world.page.waitForLoadState('networkidle', { timeout: 10000 }); + await world.page.waitForLoadState('domcontentloaded', { timeout: 10000 }); } catch { console.log(`🌍 Networkidle not reached, continuing`); } @@ -653,7 +653,7 @@ Then('I should see round {int} matchups', async function (roundNumber: number) { }); Then('I should see {int} rounds', async function (expectedRounds: number) { - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForTimeout(2000); const roundHeaders = await world.page.locator('h3:has-text("Round")').count(); expect(roundHeaders).toBe(expectedRounds); @@ -690,7 +690,7 @@ Then('I should be on the match result entry page', async function () { // View As Role Steps When('I view the navigation', async function () { - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForTimeout(1000); console.log('🌍 Viewing navigation'); }); @@ -750,7 +750,7 @@ Then('I should not see the viewing as banner', async function () { When('I go to the tournament detail page', async function () { const tournamentId = world.tournament?.id || 1; await world.page.goto(`${world.baseURL}/admin/tournaments/${tournamentId}`); - await world.page.waitForLoadState('networkidle'); + await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForTimeout(500); console.log(`🌍 Navigated to tournament detail page: ${tournamentId}`); }); diff --git a/e2e/cucumber/step-definitions/home-page-steps.ts b/e2e/cucumber/step-definitions/home-page-steps.ts new file mode 100644 index 0000000..f93ef1c --- /dev/null +++ b/e2e/cucumber/step-definitions/home-page-steps.ts @@ -0,0 +1,73 @@ +import { Given, After } from '@cucumber/cucumber'; +import { world } from '../support/world'; + +Given('there are top players in the system', async function () { + const prisma = await world.getPrisma(); + const timestamp = Date.now(); + + for (let i = 0; i < 3; i++) { + await prisma.player.create({ + data: { + name: `Home Test Player ${timestamp} ${i + 1}`, + normalizedName: `home_test_player_${timestamp}_${i + 1}`.toLowerCase(), + currentElo: 2000 - i * 10, + gamesPlayed: 10, + wins: 7, + }, + }); + } +}); + +Given('there is a club president', async function () { + const prisma = await world.getPrisma(); + const timestamp = Date.now(); + + await prisma.user.create({ + data: { + email: `president-${timestamp}@example.com`, + name: `Club President ${timestamp}`, + role: 'club_admin', + }, + }); +}); + +Given('there is a recent tournament', async function () { + const prisma = await world.getPrisma(); + const timestamp = Date.now(); + + const tournament = await prisma.event.create({ + data: { + name: `Recent Tournament ${timestamp}`, + eventType: 'tournament', + eventDate: new Date(Date.now() + 86400000), + status: 'completed', + }, + }); + + const p1 = await prisma.player.create({ + data: { name: `HP1 ${timestamp}`, normalizedName: `hp1_${timestamp}`.toLowerCase(), currentElo: 1500 }, + }); + const p2 = await prisma.player.create({ + data: { name: `HP2 ${timestamp}`, normalizedName: `hp2_${timestamp}`.toLowerCase(), currentElo: 1480 }, + }); + const p3 = await prisma.player.create({ + data: { name: `HP3 ${timestamp}`, normalizedName: `hp3_${timestamp}`.toLowerCase(), currentElo: 1450 }, + }); + const p4 = await prisma.player.create({ + data: { name: `HP4 ${timestamp}`, normalizedName: `hp4_${timestamp}`.toLowerCase(), currentElo: 1420 }, + }); + + await prisma.match.create({ + data: { + eventId: tournament.id, + player1P1Id: p1.id, + player1P2Id: p2.id, + player2P1Id: p3.id, + player2P2Id: p4.id, + team1Score: 10, + team2Score: 5, + status: 'completed', + playedAt: new Date(), + }, + }); +}); diff --git a/e2e/cucumber/support/hooks.ts b/e2e/cucumber/support/hooks.ts index e4fcbf6..332232c 100644 --- a/e2e/cucumber/support/hooks.ts +++ b/e2e/cucumber/support/hooks.ts @@ -14,22 +14,17 @@ setDefaultTimeout(30000); // Global browser instance let browser: Browser; -// Load environment files -const envPath = path.resolve(process.cwd(), '.env'); +// Load environment file (gitignored, contains dev database URL) const envDevPath = path.resolve(process.cwd(), '.env.development'); -if (fs.existsSync(envPath)) { - require('dotenv').config({ path: envPath }); -} - if (fs.existsSync(envDevPath)) { - require('dotenv').config({ path: envDevPath, override: true }); + require('dotenv').config({ path: envDevPath }); } // Database safety check - prevent tests from running against production function isProductionDatabase(): boolean { const dbUrl = process.env.DATABASE_URL || ''; - return dbUrl.includes('euchre_camp') && !dbUrl.includes('_dev') && !dbUrl.includes('test'); + return dbUrl.includes('euchre_camp') && !dbUrl.includes('_dev') && !dbUrl.includes('_ci') && !dbUrl.includes('test'); } if (isProductionDatabase()) { @@ -135,7 +130,8 @@ After(async function () { where: { OR: [ { name: { startsWith: 'Test Tournament' } }, - { name: { startsWith: 'Test Schedule Tournament' } } + { name: { startsWith: 'Test Schedule Tournament' } }, + { name: { startsWith: 'Recent Tournament' } }, ] }, select: { id: true } @@ -176,7 +172,9 @@ After(async function () { { name: { startsWith: 'Tournament Player' } }, { name: { startsWith: 'Schedule Player' } }, { name: { startsWith: 'Test Player' } }, - { name: { startsWith: 'Test Activity Player' } } + { name: { startsWith: 'Test Activity Player' } }, + { name: { startsWith: 'Home Test Player' } }, + { name: { startsWith: 'HP' } }, ] } }); @@ -184,7 +182,10 @@ After(async function () { // Delete test users await prisma.user.deleteMany({ where: { - email: { startsWith: 'cucumber-' } + OR: [ + { email: { startsWith: 'cucumber-' } }, + { email: { startsWith: 'president-' } }, + ] } }); diff --git a/e2e/elo-ratings.test.ts b/e2e/elo-ratings.test.ts index 407d6b8..e41b152 100644 --- a/e2e/elo-ratings.test.ts +++ b/e2e/elo-ratings.test.ts @@ -94,10 +94,11 @@ test.describe('Elo Rating Updates', () => { test('Elo rating updates after match upload', async ({ page }) => { // Step 1: Create test players with known initial ratings + const ts = Date.now(); const player1 = await prisma.player.create({ data: { - name: 'Elo Test Player 1', - normalizedName: 'elo test player 1', + name: `Elo Test Player 1 ${ts}`, + normalizedName: `elo_test_player_1_${ts}`, currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -107,8 +108,8 @@ test.describe('Elo Rating Updates', () => { const player2 = await prisma.player.create({ data: { - name: 'Elo Test Player 2', - normalizedName: 'elo test player 2', + name: `Elo Test Player 2 ${ts}`, + normalizedName: `elo_test_player_2_${ts}`, currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -118,8 +119,8 @@ test.describe('Elo Rating Updates', () => { const player3 = await prisma.player.create({ data: { - name: 'Elo Test Player 3', - normalizedName: 'elo test player 3', + name: `Elo Test Player 3 ${ts}`, + normalizedName: `elo_test_player_3_${ts}`, currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -129,8 +130,8 @@ test.describe('Elo Rating Updates', () => { const player4 = await prisma.player.create({ data: { - name: 'Elo Test Player 4', - normalizedName: 'elo test player 4', + name: `Elo Test Player 4 ${ts}`, + normalizedName: `elo_test_player_4_${ts}`, currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -369,10 +370,11 @@ ${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player test('Elo ratings are visible on player profile', async ({ page }) => { // Create a test player + const ts = Date.now(); const player = await prisma.player.create({ data: { - name: 'Elo Test Profile Player', - normalizedName: 'elo test profile player', + name: `Elo Test Profile Player ${ts}`, + normalizedName: `elo_test_profile_player_${ts}`, currentElo: 1750, gamesPlayed: 50, wins: 30, diff --git a/e2e/epic1-auth-logout.test.ts b/e2e/epic1-auth-logout.test.ts index 9a3f876..30ec486 100644 --- a/e2e/epic1-auth-logout.test.ts +++ b/e2e/epic1-auth-logout.test.ts @@ -12,6 +12,7 @@ import { test, expect } from '@playwright/test'; import { prisma } from '@/lib/prisma'; +const BASE_URL = process.env.CI ? 'https://euchre-ci.notsosm.art' : 'http://localhost:3000'; function getTestCredentials() { @@ -35,11 +36,11 @@ test.describe.serial('Epic 1: User Logout', () => { testName = credentials.name; // Create test user via API with proper origin header - const response = await fetch('http://localhost:3000/api/auth/sign-up/email', { + const response = await fetch(`${BASE_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Origin': 'http://localhost:3000', + 'Origin': BASE_URL, 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ @@ -92,7 +93,7 @@ test.describe.serial('Epic 1: User Logout', () => { test('Logout button appears in navigation when logged in', async ({ page }) => { // Login first - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); // Wait for page to load await page.waitForLoadState('domcontentloaded'); @@ -156,7 +157,7 @@ test.describe.serial('Epic 1: User Logout', () => { test('Logout clears session and redirects to home', async ({ page }) => { // Login first - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); @@ -180,7 +181,7 @@ test.describe.serial('Epic 1: User Logout', () => { test('After logout, protected pages redirect to login', async ({ page }) => { // Login first - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); @@ -200,7 +201,7 @@ test.describe.serial('Epic 1: User Logout', () => { await page.waitForURL('**/auth/login**', { timeout: 10000 }); // Try to access admin page - await page.goto('http://localhost:3000/admin'); + await page.goto('/admin'); // Should redirect to login await expect(page).toHaveURL(/.*auth\/login.*/); diff --git a/e2e/epic1-auth-password-reset.test.ts b/e2e/epic1-auth-password-reset.test.ts index 5f1278e..11293d7 100644 --- a/e2e/epic1-auth-password-reset.test.ts +++ b/e2e/epic1-auth-password-reset.test.ts @@ -19,7 +19,7 @@ import { test, expect } from '@playwright/test'; test.describe.skip('Epic 1: Password Reset (Not Implemented)', () => { test('Forgot password link exists on login page', async ({ page }) => { - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); // Check for forgot password link await expect(page.locator('a[href*="password-reset"]')).toBeVisible(); @@ -29,7 +29,7 @@ test.describe.skip('Epic 1: Password Reset (Not Implemented)', () => { test('Password reset page exists but is not functional', async ({ page }) => { // Note: The link exists but the page may not be implemented // This test documents the current state - await page.goto('http://localhost:3000/auth/password-reset'); + await page.goto('/auth/password-reset'); // Check if page loads (may show "not implemented" message) await expect(page.locator('body')).toBeVisible(); diff --git a/e2e/epic1-auth-registration.test.ts b/e2e/epic1-auth-registration.test.ts index 82ea5a8..b4891e8 100644 --- a/e2e/epic1-auth-registration.test.ts +++ b/e2e/epic1-auth-registration.test.ts @@ -51,7 +51,7 @@ test.describe.serial('Epic 1: User Registration', () => { }); test('Registration page exists and loads', async ({ page }) => { - await page.goto('http://localhost:3000/auth/register'); + await page.goto('/auth/register'); // Check for registration form elements await expect(page.locator('input[name="name"]')).toBeVisible(); @@ -61,7 +61,7 @@ test.describe.serial('Epic 1: User Registration', () => { }); test('Registration with valid data creates account', async ({ page }) => { - await page.goto('http://localhost:3000/auth/register'); + await page.goto('/auth/register'); // Wait for page to load await page.waitForLoadState('domcontentloaded'); @@ -122,7 +122,7 @@ test.describe.serial('Epic 1: User Registration', () => { }); test('Registration with duplicate email fails', async ({ page }) => { - await page.goto('http://localhost:3000/auth/register'); + await page.goto('/auth/register'); // Fill registration form with existing email await page.fill('input[name="name"]', testName); @@ -147,7 +147,7 @@ test.describe.serial('Epic 1: User Registration', () => { }); test('Registration with weak password fails', async ({ page }) => { - await page.goto('http://localhost:3000/auth/register'); + await page.goto('/auth/register'); // Fill registration form with weak password await page.fill('input[name="name"]', testName); @@ -162,7 +162,7 @@ test.describe.serial('Epic 1: User Registration', () => { }); test('Auto-created player profile is linked to user', async ({ page }) => { - await page.goto('http://localhost:3000/auth/register'); + await page.goto('/auth/register'); const profileEmail = `profile-${Date.now()}@example.com`; const profileName = 'Profile Test User'; diff --git a/e2e/epic3-rankings.test.ts b/e2e/epic3-rankings.test.ts index d3d6afd..c678104 100644 --- a/e2e/epic3-rankings.test.ts +++ b/e2e/epic3-rankings.test.ts @@ -15,7 +15,7 @@ import { test, expect } from '@playwright/test'; test.describe('Epic 3: Rankings Page', () => { test('Rankings page loads and displays rankings table', async ({ page }) => { - await page.goto('http://localhost:3000/rankings'); + await page.goto('/rankings'); // Check page title or heading await expect(page.locator('h1, h2')).toContainText(/rankings?/i); @@ -25,7 +25,7 @@ test.describe('Epic 3: Rankings Page', () => { }); test('Rankings table displays player columns', async ({ page }) => { - await page.goto('http://localhost:3000/rankings'); + await page.goto('/rankings'); // Check for expected column headers const table = page.locator('table'); @@ -38,7 +38,7 @@ test.describe('Epic 3: Rankings Page', () => { test('Rankings page is publicly accessible (no login required)', async ({ page }) => { // Navigate directly to rankings without logging in - await page.goto('http://localhost:3000/rankings'); + await page.goto('/rankings'); // Page should load without redirecting to login await expect(page).toHaveURL(/.*rankings.*/); diff --git a/e2e/epic4-tournament-creation.test.ts b/e2e/epic4-tournament-creation.test.ts index d702a7d..bc223cf 100644 --- a/e2e/epic4-tournament-creation.test.ts +++ b/e2e/epic4-tournament-creation.test.ts @@ -13,6 +13,7 @@ import { test, expect } from '@playwright/test'; import { prisma } from '@/lib/prisma'; +const BASE_URL = process.env.CI ? 'https://euchre-ci.notsosm.art' : 'http://localhost:3000'; function getTestCredentials() { @@ -36,11 +37,11 @@ test.describe.serial('Epic 4: Tournament Creation', () => { testName = credentials.name; // Create admin user via API - const response = await fetch('http://localhost:3000/api/auth/sign-up/email', { + const response = await fetch(`${BASE_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Origin': 'http://localhost:3000' + 'Origin': BASE_URL }, body: JSON.stringify({ email: testEmail, @@ -82,7 +83,7 @@ test.describe.serial('Epic 4: Tournament Creation', () => { test('Tournament creation page exists and loads', async ({ page }) => { // Login first - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); @@ -91,7 +92,7 @@ test.describe.serial('Epic 4: Tournament Creation', () => { await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to new tournament page - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Check for form await expect(page.locator('form')).toBeVisible(); @@ -99,7 +100,7 @@ test.describe.serial('Epic 4: Tournament Creation', () => { test('Tournament form has required fields', async ({ page }) => { // Login first - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); @@ -107,7 +108,7 @@ test.describe.serial('Epic 4: Tournament Creation', () => { // Wait for redirect to admin or player profile (indicates successful login) await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Check for required fields await expect(page.locator('input[name="name"]')).toBeVisible(); @@ -117,7 +118,7 @@ test.describe.serial('Epic 4: Tournament Creation', () => { test('Create tournament with valid data', async ({ page }) => { // Login first - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); @@ -126,7 +127,7 @@ test.describe.serial('Epic 4: Tournament Creation', () => { await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to new tournament page - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); const tournamentName = `Test Tournament ${Date.now()}`; diff --git a/e2e/global.setup.ts b/e2e/global.setup.ts index 42422a7..cfbf673 100644 --- a/e2e/global.setup.ts +++ b/e2e/global.setup.ts @@ -112,6 +112,9 @@ async function createTestUsers(config: FullConfig) { await context.storageState({ path: authFile }); console.log(`Created and authenticated test user: ${testEmail}`); + // Clear session so admin registration doesn't get redirected + await context.clearCookies(); + const adminTimestamp = timestamp + 1; const adminEmail = `setup-admin-${adminTimestamp}@example.com`; const adminPassword = 'AdminPassword123!'; diff --git a/e2e/global.teardown.ts b/e2e/global.teardown.ts index 43c7949..c2f65fa 100644 --- a/e2e/global.teardown.ts +++ b/e2e/global.teardown.ts @@ -24,6 +24,14 @@ const TEST_PATTERNS = { '%TestUser%', '%Cucumber%', '%Config Admin%', + '%Elo Test%', + '%Dedupe%', + '%Whitespace%', + '%Aggregate%', + '%Tournament Player%', + '%Schedule Player%', + '%Test Activity Player%', + '%HP%', ], events: [ '%Test%', @@ -31,12 +39,21 @@ const TEST_PATTERNS = { '%Recent%', '%Test Tournament%', '%Cucumber%', + '%Elo Test%', + '%Schedule%', ], users: [ '%test%', '%setup%', '%cucumber%', '%TestUser%', + '%logout-test%', + '%admin-%', + '%config-admin%', + '%schedule-admin%', + '%nine-part-test%', + '%tour-admin-%', + '%president-%', ] }; @@ -96,9 +113,18 @@ async function cleanupTestRecords(prisma: PrismaClient) { const eventWhere = buildLikeClause(TEST_PATTERNS.events); const userWhere = buildEmailLikeClause(TEST_PATTERNS.users); + await prisma.$executeRawUnsafe(`DELETE FROM elo_snapshots WHERE "playerId" IN (SELECT id FROM players WHERE (${playerWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM partnership_games WHERE "player1Id" IN (SELECT id FROM players WHERE (${playerWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM partnership_stats WHERE "player1Id" IN (SELECT id FROM players WHERE (${playerWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM event_participants WHERE "eventId" IN (SELECT id FROM events WHERE (${eventWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM tournament_rounds WHERE "eventId" IN (SELECT id FROM events WHERE (${eventWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM bracket_matchups WHERE "eventId" IN (SELECT id FROM events WHERE (${eventWhere}));`); await prisma.$executeRawUnsafe(`DELETE FROM events WHERE (${eventWhere});`); console.log('Deleted test events'); + await prisma.$executeRawUnsafe(`DELETE FROM elo_ratings WHERE "playerId" IN (SELECT id FROM players WHERE (${playerWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM glicko2_ratings WHERE "playerId" IN (SELECT id FROM players WHERE (${playerWhere}));`); + await prisma.$executeRawUnsafe(`DELETE FROM open_skill_ratings WHERE "playerId" IN (SELECT id FROM players WHERE (${playerWhere}));`); await prisma.$executeRawUnsafe(`DELETE FROM players WHERE (${playerWhere});`); console.log('Deleted test players'); diff --git a/e2e/home-page.test.ts b/e2e/home-page.test.ts deleted file mode 100644 index 13e2e42..0000000 --- a/e2e/home-page.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { test, expect } from '@playwright/test' -import { prisma } from '@/lib/prisma' - -test.describe('Home Page', () => { - const createdIds = { - players: [] as number[], - events: [] as number[], - matches: [] as number[], - users: [] as string[], - } - - test.afterEach(async () => { - await prisma.match.deleteMany({ where: { id: { in: createdIds.matches } } }) - await prisma.event.deleteMany({ where: { id: { in: createdIds.events } } }) - await prisma.player.deleteMany({ id: { in: createdIds.players } }) - await prisma.user.deleteMany({ where: { id: { in: createdIds.users } } }) - createdIds.players = [] - createdIds.events = [] - createdIds.matches = [] - createdIds.users = [] - }) - - test('displays top 10 players section', async ({ page }) => { - const timestamp = Date.now() - - for (let i = 0; i < 3; i++) { - const player = await prisma.player.create({ - data: { - name: `Home Test Player ${timestamp} ${i + 1}`, - normalizedName: `home_test_player_${timestamp}_${i + 1}`.toLowerCase(), - currentElo: 2000 - i * 10, - gamesPlayed: 10, - wins: 7, - }, - }) - createdIds.players.push(player.id) - } - - await page.goto('/') - - await expect(page.locator('text=Top 10 Players')).toBeVisible() - await expect( - page.locator(`a:has-text("Home Test Player ${timestamp} 1")`) - ).toBeVisible() - }) - - test('displays club president section', async ({ page }) => { - const timestamp = Date.now() - const user = await prisma.user.create({ - data: { - email: `president-${timestamp}@example.com`, - name: `Club President ${timestamp}`, - role: 'club_admin', - }, - }) - createdIds.users.push(user.id) - - await page.goto('/') - - await expect(page.locator('text=Club President')).toBeVisible() - }) - - test('displays most recent tournament section', async ({ page }) => { - const timestamp = Date.now() - - const tournament = await prisma.event.create({ - data: { - name: `Recent Tournament ${timestamp}`, - eventType: 'tournament', - eventDate: new Date(Date.now() + 86400000), - status: 'completed', - }, - }) - createdIds.events.push(tournament.id) - - const p1 = await prisma.player.create({ - data: { name: `HP1 ${timestamp}`, normalizedName: `hp1_${timestamp}`.toLowerCase(), currentElo: 1500 }, - }) - const p2 = await prisma.player.create({ - data: { name: `HP2 ${timestamp}`, normalizedName: `hp2_${timestamp}`.toLowerCase(), currentElo: 1480 }, - }) - const p3 = await prisma.player.create({ - data: { name: `HP3 ${timestamp}`, normalizedName: `hp3_${timestamp}`.toLowerCase(), currentElo: 1450 }, - }) - const p4 = await prisma.player.create({ - data: { name: `HP4 ${timestamp}`, normalizedName: `hp4_${timestamp}`.toLowerCase(), currentElo: 1420 }, - }) - createdIds.players.push(p1.id, p2.id, p3.id, p4.id) - - const match = await prisma.match.create({ - data: { - eventId: tournament.id, - player1P1Id: p1.id, - player1P2Id: p2.id, - player2P1Id: p3.id, - player2P2Id: p4.id, - team1Score: 10, - team2Score: 5, - status: 'completed', - playedAt: new Date(), - }, - }) - createdIds.matches.push(match.id) - - await page.goto('/') - - await expect(page.locator('text=Most Recent Tournament')).toBeVisible() - await expect(page.locator(`text=Recent Tournament ${timestamp}`)).toBeVisible() - }) -}) \ No newline at end of file diff --git a/e2e/schedule-tab.test.ts b/e2e/schedule-tab.test.ts index ca7a67c..e7bef21 100644 --- a/e2e/schedule-tab.test.ts +++ b/e2e/schedule-tab.test.ts @@ -13,6 +13,7 @@ import { test, expect } from '@playwright/test'; import { prisma } from '@/lib/prisma'; +const BASE_URL = process.env.CI ? 'https://euchre-ci.notsosm.art' : 'http://localhost:3000'; function getTestCredentials() { const timestamp = Date.now(); @@ -34,11 +35,11 @@ test.describe.serial('Issue #7: Schedule Tab', () => { testPassword = credentials.password; // Create admin user via API - const response = await fetch('http://localhost:3000/api/auth/sign-up/email', { + const response = await fetch(`${BASE_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Origin: 'http://localhost:3000', + Origin: BASE_URL, }, body: JSON.stringify({ email: testEmail, @@ -128,14 +129,14 @@ test.describe.serial('Issue #7: Schedule Tab', () => { test('Schedule tab link exists on tournament detail page', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to tournament detail - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`); + await page.goto(`/admin/tournaments/${tournamentId}`); // Check Schedule tab link exists const scheduleLink = page.locator('a', { hasText: 'Schedule' }); @@ -144,14 +145,14 @@ test.describe.serial('Issue #7: Schedule Tab', () => { test('Schedule page loads with no schedule message', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to schedule page - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + await page.goto(`/admin/tournaments/${tournamentId}/schedule`); // Check page content await expect(page.locator('h1')).toContainText('Tournament Schedule'); @@ -161,14 +162,14 @@ test.describe.serial('Issue #7: Schedule Tab', () => { test('Generate schedule creates rounds and matchups', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to schedule page - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + await page.goto(`/admin/tournaments/${tournamentId}/schedule`); // Click generate schedule await page.click('button:has-text("Generate Schedule")'); @@ -191,14 +192,14 @@ test.describe.serial('Issue #7: Schedule Tab', () => { test('Schedule page displays generated rounds and matchups', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to schedule page - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + await page.goto(`/admin/tournaments/${tournamentId}/schedule`); // Check that rounds are displayed await expect(page.locator('text=Round 1')).toBeVisible(); @@ -213,7 +214,7 @@ test.describe.serial('Issue #7: Schedule Tab', () => { test('Schedule API returns rounds with matchups', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); @@ -221,7 +222,7 @@ test.describe.serial('Issue #7: Schedule Tab', () => { // Call the schedule API const response = await page.request.get( - `http://localhost:3000/api/tournaments/${tournamentId}/schedule` + `/api/tournaments/${tournamentId}/schedule` ); expect(response.ok()).toBe(true); diff --git a/e2e/smoke-test.test.ts b/e2e/smoke-test.test.ts index d8717c7..1f9f61d 100644 --- a/e2e/smoke-test.test.ts +++ b/e2e/smoke-test.test.ts @@ -8,109 +8,6 @@ import { test, expect } from '@playwright/test' test.describe('Smoke Test: EuchreCamp Application', () => { - test.describe('Admin Panel Navigation', () => { - test('should navigate to admin dashboard', async ({ page }) => { - await page.goto('/admin') - // Admin dashboard should be visible - await expect(page.locator('text=Admin')).toBeVisible() - }) - - test('should navigate to matches admin page', async ({ page }) => { - await page.goto('/admin/matches') - await expect(page.locator('text=Match Management')).toBeVisible() - }) - - test('should navigate to players admin page', async ({ page }) => { - await page.goto('/admin/players') - await expect(page.locator('text=Player Management')).toBeVisible() - }) - - test('should navigate to users admin page', async ({ page }) => { - await page.goto('/admin/users') - await expect(page.locator('text=User Management')).toBeVisible() - }) - }) - - test.describe('Match Management', () => { - test('should display matches page', async ({ page }) => { - await page.goto('/admin/matches') - // Verify page header is visible - await expect(page.locator('text=Match Management')).toBeVisible() - - // Page should load successfully - verify either table or empty state is present - const hasTable = await page.locator('table').count().then(c => c > 0) - const hasEmptyState = await page.locator('text=/no matches|No matches/').count().then(c => c > 0) - - // At least one should be present - expect(hasTable || hasEmptyState).toBeTruthy() - }) - - test('should have delete button for matches when matches exist', async ({ page }) => { - await page.goto('/admin/matches') - // Check if delete buttons exist in the table - const deleteButtons = page.locator('button:has-text("Delete")') - const count = await deleteButtons.count() - // Table might be empty, but if there are matches, delete buttons should exist - if (count > 0) { - await expect(page.locator('text=Actions')).toBeVisible() - } - }) - }) - - test.describe('Player Management', () => { - test('should display players table', async ({ page }) => { - await page.goto('/admin/players') - await expect(page.locator('table')).toBeVisible() - await expect(page.locator('text=Player Name')).toBeVisible() - await expect(page.locator('text=Current Elo')).toBeVisible() - await expect(page.locator('text=Actions')).toBeVisible() - }) - - test('should have edit and delete buttons for players', async ({ page }) => { - await page.goto('/admin/players') - // At minimum, verify the Actions column exists - await expect(page.locator('text=Actions')).toBeVisible() - }) - - test('should allow editing player name', async ({ page }) => { - await page.goto('/admin/players') - // Click edit on first player - const editButton = page.locator('button:has-text("Edit")').first() - if (await editButton.isVisible()) { - await editButton.click() - - // Verify edit modal appears - await expect(page.locator('text=Edit Player Name')).toBeVisible() - - // Close modal - await page.click('text=Cancel') - await expect(page.locator('text=Edit Player Name')).not.toBeVisible() - } - }) - }) - - test.describe('User Management', () => { - test('should display users page', async ({ page }) => { - await page.goto('/admin/users') - // Page should load with either a table or "no users" message - const hasTable = await page.locator('table').isVisible().catch(() => false) - const hasNoUsers = await page.locator('text=No users found').isVisible().catch(() => false) - - // At least one of these should be true - expect(hasTable || hasNoUsers).toBeTruthy() - - // Verify page header is visible - await expect(page.locator('text=User Management')).toBeVisible() - }) - - test('should have create user link', async ({ page }) => { - await page.goto('/admin/users') - // Check for the main create user link (with green button styling) - const createLink = page.locator('a.bg-green-600:has-text("Create User")') - await expect(createLink).toBeVisible() - }) - }) - test.describe('Public Pages', () => { test('should display rankings page', async ({ page }) => { await page.goto('/rankings') diff --git a/e2e/team-configuration.test.ts b/e2e/team-configuration.test.ts index bcfb0c6..b30b564 100644 --- a/e2e/team-configuration.test.ts +++ b/e2e/team-configuration.test.ts @@ -7,6 +7,7 @@ import { test, expect } from '@playwright/test'; import { prisma } from '@/lib/prisma'; +const BASE_URL = process.env.CI ? 'https://euchre-ci.notsosm.art' : 'http://localhost:3000'; function getTestCredentials() { const timestamp = Date.now(); @@ -28,11 +29,11 @@ test.describe.serial('Issue #22: Team Configuration', () => { testPassword = credentials.password; // Create admin user via API - const response = await fetch('http://localhost:3000/api/auth/sign-up/email', { + const response = await fetch(`${BASE_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Origin: 'http://localhost:3000', + Origin: BASE_URL, }, body: JSON.stringify({ email: testEmail, @@ -77,14 +78,14 @@ test.describe.serial('Issue #22: Team Configuration', () => { test('Tournament creation form shows team configuration options', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to tournament creation - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Select Round Robin format await page.selectOption('select[name="format"]', 'round_robin'); @@ -100,14 +101,14 @@ test.describe.serial('Issue #22: Team Configuration', () => { test('Create tournament with permanent teams', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to tournament creation - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Fill in tournament details await page.fill('input[name="name"]', `Test Tournament ${Date.now()}`); @@ -175,14 +176,14 @@ test.describe.serial('Issue #22: Team Configuration', () => { test('Create tournament with variable teams and partner rotation', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to tournament creation - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Fill in tournament details await page.fill('input[name="name"]', `Variable Teams Tournament ${Date.now()}`); @@ -237,11 +238,11 @@ test.describe.serial('Issue #22: Team Configuration', () => { test('Edit tournament team configuration', async ({ page }) => { // First create a tournament with default settings - const createResponse = await fetch('http://localhost:3000/api/tournaments', { + const createResponse = await fetch(`${BASE_URL}/api/tournaments`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Origin: 'http://localhost:3000', + Origin: BASE_URL, }, body: JSON.stringify({ name: `Edit Test Tournament ${Date.now()}`, @@ -253,14 +254,14 @@ test.describe.serial('Issue #22: Team Configuration', () => { tournamentId = createData.tournament.id; // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to edit tournament page - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/edit`); + await page.goto(`/admin/tournaments/${tournamentId}/edit`); // Check that team configuration section is visible await expect(page.locator('text=Team Configuration')).toBeVisible(); diff --git a/e2e/test-base-url.ts b/e2e/test-base-url.ts new file mode 100644 index 0000000..20b6cbf --- /dev/null +++ b/e2e/test-base-url.ts @@ -0,0 +1,3 @@ +export const BASE_URL = process.env.BASE_URL || (process.env.CI + ? 'https://euchre-ci.notsosm.art' + : 'http://localhost:3000'); diff --git a/e2e/tournament-9-participants-variable.test.ts b/e2e/tournament-9-participants-variable.test.ts index b24ec2c..bdaec7a 100644 --- a/e2e/tournament-9-participants-variable.test.ts +++ b/e2e/tournament-9-participants-variable.test.ts @@ -18,6 +18,7 @@ import { test, expect } from '@playwright/test'; import { prisma } from '@/lib/prisma'; +const BASE_URL = process.env.CI ? 'https://euchre-ci.notsosm.art' : 'http://localhost:3000'; function getTestCredentials() { const timestamp = Date.now(); @@ -41,11 +42,11 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili const timestamp = Date.now(); // Create admin user via API - const response = await fetch('http://localhost:3000/api/auth/sign-up/email', { + const response = await fetch(`${BASE_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Origin: 'http://localhost:3000', + Origin: BASE_URL, }, body: JSON.stringify({ email: testEmail, @@ -102,14 +103,14 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili test('Tournament creation form shows variable team durability options', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to tournament creation - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Select Round Robin format await page.selectOption('select[name="format"]', 'round_robin'); @@ -132,14 +133,14 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili test('Create tournament with 10 participants, variable teams, and minimize_repeat', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to tournament creation - await page.goto('http://localhost:3000/admin/tournaments/new'); + await page.goto('/admin/tournaments/new'); // Fill in tournament details const tournamentName = `9 Participant Variable Tournament ${Date.now()}`; @@ -238,7 +239,7 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili test('Schedule generation for 10 participants creates correct number of matchups', async ({ page }) => { // Navigate to Matchups tab (formerly Teams tab) // The test is already authenticated via the chromium-admin project - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`); + await page.goto(`/admin/tournaments/${tournamentId}`); // Wait for page to load and data to be fetched await page.waitForLoadState('domcontentloaded'); @@ -280,14 +281,14 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili test('Schedule displays correct matchups for 10 participants', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to Schedule tab - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + await page.goto(`/admin/tournaments/${tournamentId}/schedule`); // Verify rounds are displayed await expect(page.locator('text=Round 1')).toBeVisible(); @@ -304,14 +305,14 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili test('Matchup generation with minimize_repeat creates varied partnerships', async ({ page }) => { // Login - await page.goto('http://localhost:3000/auth/login'); + await page.goto('/auth/login'); await page.fill('input[name="email"]', testEmail); await page.fill('input[name="password"]', testPassword); await page.click('button[type="submit"]'); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); // Navigate to Schedule tab - await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + await page.goto(`/admin/tournaments/${tournamentId}/schedule`); // Get all matchups from the database to verify partnership variety const matchups = await prisma.bracketMatchup.findMany({ diff --git a/playwright.config.ts b/playwright.config.ts index cf8a29d..ef8ca66 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,8 +12,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, // Retry on CI only. retries: process.env.CI ? 1 : 0, - // Use 2 workers in CI for parallel project execution; 1 locally - workers: process.env.CI ? 2 : 1, + // Use 1 worker in CI to avoid database conflicts between parallel projects + workers: 1, // Reporter to use reporter: 'html', // Global setup and teardown @@ -43,6 +43,7 @@ export default defineConfig({ storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], + testIgnore: ['**/admin-*.test.ts'], }, // Admin user project { @@ -63,6 +64,7 @@ export default defineConfig({ storageState: undefined, }, dependencies: ['setup'], + testIgnore: ['**/admin-*.test.ts'], }, ], // Run your local dev server before starting the tests