Compare commits
75 Commits
v0.1.0
..
1ec51f9c6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec51f9c6a | |||
| b2876d1e49 | |||
| a24782f4bb | |||
| 1bece10df2 | |||
| b96932270b | |||
| 58857af5ca | |||
| 39d3250114 | |||
| 18418adf9e | |||
| d2824695c0 | |||
| 3d87f3e1dc | |||
| 04dfdfa378 | |||
| 2cf832eeac | |||
| 967bdc1b89 | |||
| a3cd46e39a | |||
| 42902106e6 | |||
| 1503420519 | |||
| f8f9c205be | |||
| d8a8931bc3 | |||
| e921f17d2c | |||
| c993852147 | |||
| 96a7454d2f | |||
| ed43399b24 | |||
| e8f0fd2538 | |||
| 2e98e9eb51 | |||
| 3a7a9b4b8e | |||
| 66ee38f456 | |||
| 4e3b25e2fc | |||
| e4c4333b40 | |||
| 09302fd911 | |||
| 0d9707ea27 | |||
| b90ec08966 | |||
| 1cd2cbd0a6 | |||
| 24db43eb7f | |||
| 1c9f70c3ed | |||
| 5caa284b3c | |||
| ec3297befe | |||
| f4aca275de | |||
| fe133eab99 | |||
| 89d0d08162 | |||
| fe18a8b9fe | |||
| 6b9b690947 | |||
| 501e1b7e23 | |||
| 9768ff3517 | |||
| 0343fa91d2 | |||
| b654571a37 | |||
| 0090611994 | |||
| 9416159712 | |||
| d39172ca44 | |||
| cd119694ed | |||
| 3e4e896677 | |||
| 5e2dbbc2be | |||
| da9c6ae70b | |||
| 8cf3f2d401 | |||
| 69abec3b87 | |||
| 92c064c264 | |||
| 59009f0bf7 | |||
| 381bf630c7 | |||
| 1f4bdc2756 | |||
| f9d6321721 | |||
| b38b88f67d | |||
| 237f128779 | |||
| 6989decf6f | |||
| 4e112c92ae | |||
| a9fc5c312a | |||
| abc1963aea | |||
| 6cef0ac342 | |||
| 74e6180a35 | |||
| 8440962649 | |||
| 255b330695 | |||
| 8c9a8b0d9f | |||
| a82ec3c9fc | |||
| b5af4b2e34 | |||
| 26c5158724 | |||
| 48a96ce65f | |||
| 1f9f708f19 |
@@ -0,0 +1,15 @@
|
|||||||
|
# Development environment configuration for EuchreCamp
|
||||||
|
# Copy this file to .env.development and fill in your values
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_PROVIDER=postgresql
|
||||||
|
# Development database URL - must contain "_dev" to pass safety checks
|
||||||
|
DATABASE_URL="postgresql://euchre_camp:password@localhost:5432/euchre_camp_dev"
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
BETTER_AUTH_SECRET="your-secret-key-change-this"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
TRUSTED_ORIGINS="http://localhost:3000,http://127.0.0.1:3000"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# EuchreCamp Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Database Configuration
|
||||||
|
# ============================================
|
||||||
|
# PostgreSQL connection string
|
||||||
|
# Format: postgresql://username:password@host:port/database
|
||||||
|
DATABASE_URL=postgresql://euchre:euchrepassword@localhost:5432/euchre_camp
|
||||||
|
|
||||||
|
# Shadow database for Prisma migrations (optional for PostgreSQL)
|
||||||
|
DATABASE_SHADOW_URL=postgresql://euchre:euchrepassword@localhost:5432/euchre_camp_shadow
|
||||||
|
|
||||||
|
# Database provider (postgresql, mysql, sqlite, etc.)
|
||||||
|
DATABASE_PROVIDER=postgresql
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Better Auth Configuration
|
||||||
|
# ============================================
|
||||||
|
# Secret key for session encryption (generate a strong random string)
|
||||||
|
# Run: openssl rand -base64 32
|
||||||
|
BETTER_AUTH_SECRET=your-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Base URL for authentication callbacks
|
||||||
|
# For production: https://your-domain.com
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Application Configuration
|
||||||
|
# ============================================
|
||||||
|
# Environment: development, production, test
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Trusted origins for CORS and authentication
|
||||||
|
# Add your domain(s) for production
|
||||||
|
TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Optional: External Services
|
||||||
|
# ============================================
|
||||||
|
# If using external database (e.g., Supabase, Railway)
|
||||||
|
# DATABASE_URL=postgresql://user:pass@host:port/db
|
||||||
|
|
||||||
|
# If using external auth provider
|
||||||
|
# BETTER_AUTH_URL=https://your-app.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CasaOS Deployment Notes
|
||||||
|
# ============================================
|
||||||
|
# When deploying to CasaOS, set these via the UI:
|
||||||
|
# 1. DATABASE_URL: Your PostgreSQL connection string
|
||||||
|
# 2. BETTER_AUTH_SECRET: Generate with: openssl rand -base64 32
|
||||||
|
# 3. BETTER_AUTH_URL: Your app's public URL
|
||||||
|
# 4. TRUSTED_ORIGINS: Your app's public URL(s)
|
||||||
+11
-1
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
".next",
|
".next",
|
||||||
"out",
|
"out",
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Gitea Actions Workflow Architecture
|
||||||
|
|
||||||
|
This document describes the workflow architecture for version bumping, testing, and releases.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The workflow architecture uses a three-step process:
|
||||||
|
1. **PR Workflow**: Runs tests on PRs and analyzes commits for bump type
|
||||||
|
2. **Test Workflow**: Runs unit tests on all branch pushes (except version bumps)
|
||||||
|
3. **Release Workflow**: Bumps version, creates tags, builds Docker images, and deploys on main branch pushes
|
||||||
|
|
||||||
|
## Workflow Files
|
||||||
|
|
||||||
|
### 1. `.gitea/workflows/pr.yml` (Pull Request Workflow)
|
||||||
|
|
||||||
|
**Trigger**: Pull requests to `main` branch
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
- Run unit tests on every PR (fast feedback)
|
||||||
|
- Run acceptance tests with SQLite database
|
||||||
|
- Analyze commits to determine bump type (major/minor/patch)
|
||||||
|
- Comment the suggested bump type on the PR
|
||||||
|
|
||||||
|
**Test Execution**:
|
||||||
|
- **Unit Tests**: Run first, fast execution
|
||||||
|
- **Acceptance Tests**: Run after unit tests pass, uses SQLite database
|
||||||
|
- **Database**: SQLite with `DATABASE_URL=file:./prisma/ci.db`
|
||||||
|
- **Secrets**: Uses `BETTER_AUTH_SECRET` for authentication
|
||||||
|
|
||||||
|
**Bump Type Detection**:
|
||||||
|
- **Major**: Breaking changes detected (`BREAKING CHANGE` or `!:` in commit messages)
|
||||||
|
- **Minor**: Feature commits detected (`feat:` prefix)
|
||||||
|
- **Patch**: Default for fixes and other changes
|
||||||
|
|
||||||
|
### 2. `.gitea/workflows/test.yml` (Test Workflow)
|
||||||
|
|
||||||
|
**Trigger**: Pushes to any branch (including main)
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
- Run unit tests on all branch pushes
|
||||||
|
- Skip auto-generated version bump commits (handled by release workflow)
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Runs on all branches including main
|
||||||
|
- Skips commits with "chore: bump version" message
|
||||||
|
- Fast execution for quick feedback
|
||||||
|
|
||||||
|
### 3. `.gitea/workflows/release.yml` (Release Workflow)
|
||||||
|
|
||||||
|
**Trigger**: Pushes to `main` branch (after PR merge)
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
- Determine bump type from merge commit or PR commits
|
||||||
|
- Bump version in `package.json` and `CHANGELOG.md`
|
||||||
|
- Commit the version bump
|
||||||
|
- Create git tag for the release
|
||||||
|
- Run tests inside Docker container (with PostgreSQL)
|
||||||
|
- Build production Docker image
|
||||||
|
- Push images to registry
|
||||||
|
- Deploy to dev environment
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Skips commits that are auto-generated version bumps
|
||||||
|
- Uses `DOCKER_LOGIN` and `DOCKER_PASSWORD` secrets for registry auth
|
||||||
|
- Handles existing git tags gracefully
|
||||||
|
- Runs comprehensive tests in production-like environment
|
||||||
|
|
||||||
|
## Version Bump Logic
|
||||||
|
|
||||||
|
### Step 1: PR Analysis (pr.yml)
|
||||||
|
When a PR is opened or updated:
|
||||||
|
1. Fetch the merge base with `main`
|
||||||
|
2. Analyze all commits in the PR
|
||||||
|
3. Determine bump type based on commit messages:
|
||||||
|
- Breaking changes → major
|
||||||
|
- Features → minor
|
||||||
|
- Fixes → patch
|
||||||
|
4. Comment the suggested bump type on the PR
|
||||||
|
|
||||||
|
### Step 2: Release (release.yml)
|
||||||
|
When a PR is merged to `main`:
|
||||||
|
1. Check if the commit is an auto-bump (skip if so)
|
||||||
|
2. Analyze the merge commit message or PR commits
|
||||||
|
3. Run `node scripts/bump-version.js <type> --yes`
|
||||||
|
4. Commit the version bump changes
|
||||||
|
5. Create and push git tag
|
||||||
|
6. Build and push Docker images
|
||||||
|
|
||||||
|
## Environment Variables & Secrets
|
||||||
|
|
||||||
|
### Required Secrets
|
||||||
|
- `DOCKER_LOGIN`: Username for Docker registry authentication
|
||||||
|
- `DOCKER_PASSWORD`: Password for Docker registry authentication
|
||||||
|
- `GITEA_TOKEN` (optional): For pushing back to repo (if needed)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- `REGISTRY`: Docker registry URL (default: `docker.notsosm.art`)
|
||||||
|
- `IMAGE_NAME`: Docker image name (default: `euchre-camp`)
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
### Scenario: Feature PR
|
||||||
|
1. Developer opens PR with commits:
|
||||||
|
- "feat: add new feature"
|
||||||
|
- "fix: resolve edge case"
|
||||||
|
2. PR workflow runs:
|
||||||
|
- Unit tests pass
|
||||||
|
- Bump type analysis suggests "minor"
|
||||||
|
- Comment posted on PR: "Suggested bump: MINOR"
|
||||||
|
3. Developer merges PR
|
||||||
|
4. Release workflow runs:
|
||||||
|
- Detects minor bump from commits
|
||||||
|
- Bumps version from 0.1.1 → 0.2.0
|
||||||
|
- Commits version bump
|
||||||
|
- Creates tag v0.2.0
|
||||||
|
- Builds and pushes Docker image
|
||||||
|
- Deploys to dev
|
||||||
|
|
||||||
|
### Scenario: Breaking Change PR
|
||||||
|
1. Developer opens PR with commit:
|
||||||
|
- "feat!: breaking API change"
|
||||||
|
2. PR workflow runs:
|
||||||
|
- Detects breaking change marker
|
||||||
|
- Suggests "major" bump
|
||||||
|
- Comments on PR
|
||||||
|
3. Developer merges PR
|
||||||
|
4. Release workflow runs:
|
||||||
|
- Detects major bump
|
||||||
|
- Bumps version from 0.1.1 → 1.0.0
|
||||||
|
- Creates tag v1.0.0
|
||||||
|
- Proceeds with build and deploy
|
||||||
|
|
||||||
|
## 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 Production
|
||||||
|
- **Usage**: Release workflow runs tests in Docker with PostgreSQL
|
||||||
|
- **Configuration**: Uses dummy PostgreSQL URL for Docker builds
|
||||||
|
- **Benefits**: Production-like environment, catches PostgreSQL-specific issues
|
||||||
|
|
||||||
|
### Database Provider Detection
|
||||||
|
The application automatically detects the database provider:
|
||||||
|
- `DATABASE_PROVIDER` environment variable (defaults to `sqlite`)
|
||||||
|
- `prisma.ts` conditionally uses PrismaPg adapter for PostgreSQL
|
||||||
|
- Better Auth configured with appropriate provider
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **No CI Loops**: Version bump commits are detected and skipped
|
||||||
|
2. **Clear Communication**: PR comments inform developers of impact
|
||||||
|
3. **Semantic Versioning**: Automated adherence to semver rules
|
||||||
|
4. **Traceability**: Git tags and changelog reflect all changes
|
||||||
|
5. **Safe Releases**: Tests run before version bump and deployment
|
||||||
|
6. **Fast CI**: SQLite tests run quickly without database server setup
|
||||||
|
7. **Comprehensive Testing**: Both unit and acceptance tests in PR workflow
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Add GitHub/Gitea Release creation
|
||||||
|
- Slack/Discord notifications on release
|
||||||
|
- Automatic rollback on test failure
|
||||||
|
- Multi-environment deployment (dev/staging/prod)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Build CI Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile.ci-base'
|
||||||
|
- 'package.json'
|
||||||
|
- 'bun.lockb'
|
||||||
|
- '.gitea/workflows/build-ci-images.yml'
|
||||||
|
schedule:
|
||||||
|
# Weekly rebuild to get latest Playwright/Bun versions
|
||||||
|
- cron: '0 2 * * 0' # Every Sunday at 2 AM
|
||||||
|
workflow_dispatch: # Manual trigger
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.notsosm.art
|
||||||
|
IMAGE_NAME: euchre-camp
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ci-base:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DOCKER_LOGIN }} --password-stdin
|
||||||
|
|
||||||
|
- name: Extract metadata for CI base image
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
# Get Playwright version from package.json
|
||||||
|
PLAYWRIGHT_VERSION=$(grep -o '"@playwright/test": "[^"]*"' package.json | cut -d'"' -f4)
|
||||||
|
echo "playwright_version=${PLAYWRIGHT_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Get Bun version (latest)
|
||||||
|
BUN_VERSION=$(bun --version 2>/dev/null || echo "latest")
|
||||||
|
echo "bun_version=${BUN_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Set tags
|
||||||
|
echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:playwright-${PLAYWRIGHT_VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push CI base image
|
||||||
|
run: |
|
||||||
|
# Build with multiple tags
|
||||||
|
docker build \
|
||||||
|
--file Dockerfile.ci-base \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:latest \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:playwright-${{ steps.meta.outputs.playwright_version }} \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:${{ github.sha }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
# Push all tags
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:latest
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:playwright-${{ steps.meta.outputs.playwright_version }}
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/ci-base:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Clean up
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker logout ${{ env.REGISTRY }}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Pull Request
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker.notsosm.art/euchre-camp/ci-base:latest
|
||||||
|
options: --user root
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: bun x prisma generate
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://user:pass@localhost:5432/dummy
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: bun test src/__tests__/unit/ src/__tests__/*.test.tsx src/__tests__/auth-simple.test.ts
|
||||||
|
|
||||||
|
analyze-bump-type:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: unit-tests
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Analyze commits for bump type
|
||||||
|
id: bump_type
|
||||||
|
run: |
|
||||||
|
# Get the merge base and PR commits
|
||||||
|
git fetch origin main:main 2>/dev/null || true
|
||||||
|
MERGE_BASE=$(git merge-base HEAD main 2>/dev/null || echo "HEAD~1")
|
||||||
|
|
||||||
|
# Analyze commits in this PR
|
||||||
|
COMMITS=$(git log --oneline ${MERGE_BASE}..HEAD 2>/dev/null || git log --oneline -10)
|
||||||
|
|
||||||
|
echo "Commits in this PR:"
|
||||||
|
echo "$COMMITS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Determine bump type
|
||||||
|
if echo "$COMMITS" | grep -qE "(BREAKING CHANGE|!:)"; then
|
||||||
|
BUMP="major"
|
||||||
|
REASON="Breaking change detected in commit messages"
|
||||||
|
elif echo "$COMMITS" | grep -qE "^feat"; then
|
||||||
|
BUMP="minor"
|
||||||
|
REASON="Feature commits detected"
|
||||||
|
else
|
||||||
|
BUMP="patch"
|
||||||
|
REASON="Defaulting to patch (fixes or other changes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Suggested bump type: $BUMP"
|
||||||
|
echo "Reason: $REASON"
|
||||||
|
echo "bump=$BUMP" >> $GITHUB_OUTPUT
|
||||||
|
echo "reason=$REASON" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Comment bump type on PR
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const bump = '${{ steps.bump_type.outputs.bump }}';
|
||||||
|
const reason = '${{ steps.bump_type.outputs.reason }}';
|
||||||
|
|
||||||
|
const comment = `
|
||||||
|
## 🏷️ Version Bump Analysis
|
||||||
|
|
||||||
|
**Suggested bump type:** \`${bump.toUpperCase()}\`
|
||||||
|
**Reason:** ${reason}
|
||||||
|
|
||||||
|
This PR will bump the version to the next ${bump} version when merged.
|
||||||
|
`;
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.notsosm.art
|
||||||
|
IMAGE_NAME: euchre-camp
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Skip if this is an auto-generated version bump commit
|
||||||
|
if: "!contains(github.event.head_commit.message, 'chore: bump version')"
|
||||||
|
container:
|
||||||
|
image: docker.notsosm.art/euchre-camp/ci-base:latest
|
||||||
|
options: --user root
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git config user.email "actions@gitea.com"
|
||||||
|
|
||||||
|
- name: Determine version bump type
|
||||||
|
id: bump_type
|
||||||
|
run: |
|
||||||
|
# Get the merge commit message or use commits since last tag
|
||||||
|
MERGE_MSG="${{ github.event.head_commit.message }}"
|
||||||
|
echo "Commit message: $MERGE_MSG"
|
||||||
|
|
||||||
|
# Determine bump type from commit message or commits
|
||||||
|
if echo "$MERGE_MSG" | grep -qE "(BREAKING CHANGE|!:)"; then
|
||||||
|
echo "bump=major" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bump type: major (breaking change detected)"
|
||||||
|
elif echo "$MERGE_MSG" | grep -qE "^feat"; then
|
||||||
|
echo "bump=minor" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bump type: minor (feature detected)"
|
||||||
|
else
|
||||||
|
# Check actual commits in the PR
|
||||||
|
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
if [ -n "$LAST_TAG" ]; then
|
||||||
|
COMMITS=$(git log --oneline ${LAST_TAG}..HEAD 2>/dev/null | head -5)
|
||||||
|
else
|
||||||
|
COMMITS=$(git log --oneline | head -5)
|
||||||
|
fi
|
||||||
|
echo "Recent commits: $COMMITS"
|
||||||
|
|
||||||
|
if echo "$COMMITS" | grep -qE "BREAKING\|!:"; then
|
||||||
|
echo "bump=major" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bump type: major (breaking change in commits)"
|
||||||
|
elif echo "$COMMITS" | grep -qE "^feat"; then
|
||||||
|
echo "bump=minor" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bump type: minor (feature in commits)"
|
||||||
|
else
|
||||||
|
echo "bump=patch" >> $GITHUB_OUTPUT
|
||||||
|
echo "Bump type: patch (default)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bump version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
BUMP="${{ steps.bump_type.outputs.bump }}"
|
||||||
|
echo "Bumping version: $BUMP"
|
||||||
|
|
||||||
|
# Run the bump script
|
||||||
|
bun run scripts/bump-version.js "$BUMP" --yes
|
||||||
|
|
||||||
|
# Get new version
|
||||||
|
NEW_VERSION=$(bun -e "console.log(require('./package.json').version)")
|
||||||
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "New version: $NEW_VERSION"
|
||||||
|
|
||||||
|
- name: Commit version bump
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
git add package.json CHANGELOG.md
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit (version may already be at target version)"
|
||||||
|
echo "committed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
git commit -m "chore: bump version to v${{ steps.version.outputs.new_version }}"
|
||||||
|
git push origin main
|
||||||
|
echo "committed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create git tag for release
|
||||||
|
if: steps.commit.outputs.committed == 'true'
|
||||||
|
run: |
|
||||||
|
TAG_NAME="v${{ steps.version.outputs.new_version }}"
|
||||||
|
echo "Creating tag $TAG_NAME"
|
||||||
|
|
||||||
|
# Check if tag already exists
|
||||||
|
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
||||||
|
echo "Tag $TAG_NAME already exists, skipping tag creation"
|
||||||
|
else
|
||||||
|
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
|
||||||
|
git push origin "$TAG_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build test-capable image
|
||||||
|
if: steps.commit.outputs.committed == 'true'
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--target test-runner \
|
||||||
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
|
-t ${{ env.IMAGE_NAME }}-test:${{ steps.version.outputs.new_version }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Run tests inside test-capable container
|
||||||
|
if: steps.commit.outputs.committed == 'true'
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" \
|
||||||
|
${{ env.IMAGE_NAME }}-test:${{ steps.version.outputs.new_version }} \
|
||||||
|
bun test 'src/__tests__/unit/**' 'src/__tests__/*.test.tsx' 'src/__tests__/auth-simple.test.ts'
|
||||||
|
|
||||||
|
- name: Build production image
|
||||||
|
if: steps.commit.outputs.committed == 'true'
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--target runner \
|
||||||
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.new_version }} \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Docker images
|
||||||
|
if: steps.commit.outputs.committed == 'true'
|
||||||
|
run: |
|
||||||
|
echo "Pushing to ${{ env.REGISTRY }}..."
|
||||||
|
# Check if we can authenticate to the registry using DOCKER_LOGIN and DOCKER_PASSWORD secrets
|
||||||
|
if [ -n "${{ secrets.DOCKER_LOGIN }}" ] && [ -n "${{ secrets.DOCKER_PASSWORD }}" ]; then
|
||||||
|
if docker login ${{ env.REGISTRY }} -u ${{ secrets.DOCKER_LOGIN }} -p ${{ secrets.DOCKER_PASSWORD }} 2>/dev/null; then
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.new_version }}
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
echo "Successfully pushed images to ${{ env.REGISTRY }}"
|
||||||
|
else
|
||||||
|
echo "Warning: Docker login failed with provided credentials"
|
||||||
|
echo "Images built locally but not pushed to registry"
|
||||||
|
echo "Manual push required:"
|
||||||
|
echo " docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.new_version }}"
|
||||||
|
echo " docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: DOCKER_LOGIN or DOCKER_PASSWORD secrets not configured"
|
||||||
|
echo "Images built locally but not pushed to registry"
|
||||||
|
echo "Manual push required:"
|
||||||
|
echo " docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.new_version }}"
|
||||||
|
echo " docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to dev environment
|
||||||
|
if: steps.commit.outputs.committed == 'true'
|
||||||
|
run: |
|
||||||
|
echo "Deploying version ${{ steps.version.outputs.new_version }} to dev environment..."
|
||||||
|
|
||||||
|
# Update docker-compose.yml with new image tag using full registry path
|
||||||
|
# The registry is docker.notsosm.art and image is euchre-camp
|
||||||
|
IMAGE_TAG="${{ steps.version.outputs.new_version }}"
|
||||||
|
sed -i "s|image: docker.notsosm.art/euchre-camp:[0-9.]*|image: docker.notsosm.art/euchre-camp:${IMAGE_TAG}|" docker-compose.yml
|
||||||
|
|
||||||
|
# Copy the updated docker-compose.yml to the deployment location
|
||||||
|
# The runners are on the same Docker server where the container is running
|
||||||
|
sudo mkdir -p /home/euchre_camp
|
||||||
|
sudo cp docker-compose.yml /home/euchre_camp/
|
||||||
|
sudo chown -R euchre:euchre /home/euchre_camp
|
||||||
|
|
||||||
|
# Pull and restart the dev container
|
||||||
|
cd /home/euchre_camp
|
||||||
|
docker-compose pull app
|
||||||
|
docker-compose up -d app
|
||||||
|
|
||||||
|
# Wait for container to be healthy
|
||||||
|
echo "Waiting for container to start..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if docker ps --filter "name=euchre-camp-app" --format "{{.Status}}" | grep -q "Up"; then
|
||||||
|
echo "✅ Dev environment successfully deployed with version ${{ steps.version.outputs.new_version }}"
|
||||||
|
else
|
||||||
|
echo "❌ Dev environment deployment failed"
|
||||||
|
docker-compose logs app
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+8
-1
@@ -34,7 +34,14 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
# Allow example env files to be tracked
|
||||||
|
!.env.example
|
||||||
|
!.env.development.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ EuchreCamp is a Next.js 14+ application for managing Euchre tournaments and trac
|
|||||||
### Key Technologies
|
### Key Technologies
|
||||||
- **Next.js 14+** (App Router)
|
- **Next.js 14+** (App Router)
|
||||||
- **TypeScript**
|
- **TypeScript**
|
||||||
- **Prisma ORM** (SQLite)
|
- **Prisma ORM** (SQLite/PostgreSQL)
|
||||||
- **Tailwind CSS**
|
- **Tailwind CSS**
|
||||||
- **Better Auth** (Authentication)
|
- **Better Auth** (Authentication)
|
||||||
- **Vitest** (Unit Testing)
|
- **Bun** (Package Manager & Test Runner)
|
||||||
- **Playwright** (Acceptance Testing)
|
- **Playwright** (Acceptance Testing)
|
||||||
|
- **Vitest** (Legacy - migrated to Bun test runner)
|
||||||
|
|
||||||
## Architecture Patterns
|
## Architecture Patterns
|
||||||
|
|
||||||
@@ -37,13 +38,55 @@ EuchreCamp is a Next.js 14+ application for managing Euchre tournaments and trac
|
|||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
|
### Package Manager: Bun
|
||||||
|
|
||||||
|
This project uses **Bun** as the package manager and test runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Run unit/component tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Run unit tests only
|
||||||
|
bun run test:unit
|
||||||
|
|
||||||
|
# Run component tests only
|
||||||
|
bun run test:component
|
||||||
|
|
||||||
|
# Run acceptance tests (Playwright)
|
||||||
|
bun run test:acceptance
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
bun run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: E2E tests still use Playwright, as Bun's test runner doesn't support browser automation. Unit and component tests have been migrated to Bun's native test runner for faster execution. E2E tests are located in the `e2e/` directory (not `src/__tests__/e2e/`).
|
||||||
|
|
||||||
|
### CI/CD with Bun
|
||||||
|
|
||||||
|
All CI/CD workflows have been updated to use Bun:
|
||||||
|
|
||||||
|
- **Dockerfile**: Uses `oven/bun:alpine` base image for all stages
|
||||||
|
- **PR Workflow**: Uses `oven/setup-bun` action and `bun install`, `bun test`
|
||||||
|
- **Release Workflow**: Uses Bun for version bumping and Docker builds
|
||||||
|
|
||||||
|
**Note**: The `test.yml` workflow has been removed as it's redundant with the PR workflow.
|
||||||
|
|
||||||
### Admin User Creation
|
### Admin User Creation
|
||||||
|
|
||||||
To create an admin user, use the provided scripts:
|
To create an admin user, use the provided scripts:
|
||||||
|
|
||||||
**Option 1: Using Better Auth API (Recommended)**
|
**Option 1: Using Better Auth API (Recommended)**
|
||||||
```bash
|
```bash
|
||||||
node scripts/create-admin-via-api.js
|
bun run scripts/create-admin-via-api.js
|
||||||
```
|
```
|
||||||
This creates the admin user `david@dhg.lol` with password `adminadmin` using Better Auth's internal API.
|
This creates the admin user `david@dhg.lol` with password `adminadmin` using Better Auth's internal API.
|
||||||
|
|
||||||
@@ -109,10 +152,73 @@ npm run db:setup-postgres
|
|||||||
**Note:** The database provider is automatically detected by Better Auth and Prisma.
|
**Note:** The database provider is automatically detected by Better Auth and Prisma.
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
|
**Using just (recommended):**
|
||||||
|
- **All tests**: `just test` (unit + acceptance with SQLite)
|
||||||
|
- **Unit tests**: `just test-unit`
|
||||||
|
- **Acceptance tests (SQLite)**: `just test-acceptance-sqlite`
|
||||||
|
- **Acceptance tests (PostgreSQL)**: `just test-acceptance-postgres`
|
||||||
|
- **PR validation**: `just pr-validate` (what runs on pull requests)
|
||||||
|
|
||||||
|
**Using npm scripts:**
|
||||||
- **Unit tests**: `npm run test`
|
- **Unit tests**: `npm run test`
|
||||||
- **Acceptance tests**: `npm run test:acceptance`
|
- **Acceptance tests**: `npm run test:acceptance`
|
||||||
- **Specific test**: `npm run test:acceptance -- --grep "test name"`
|
- **Specific test**: `npm run test:acceptance -- --grep "test name"`
|
||||||
|
|
||||||
|
**CI-style acceptance tests with SQLite:**
|
||||||
|
```bash
|
||||||
|
DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI Runner Image
|
||||||
|
|
||||||
|
**Note:** The CI runner image approach has been deprecated for Gitea Actions workflows.
|
||||||
|
|
||||||
|
The original attempt to use a pre-built CI runner image with pre-installed dependencies encountered fundamental issues with how Gitea Actions handles workspace mounting. When Gitea Actions runs a container job, it mounts the workspace at a specific path (e.g., `/workspace/david/euchre_camp`), which hides the container's `/app` directory where dependencies were installed.
|
||||||
|
|
||||||
|
**Current Approach:**
|
||||||
|
- Workflows use standard `node:20-alpine` or `mcr.microsoft.com/playwright` containers
|
||||||
|
- Dependencies are installed via `npm ci` in each workflow run
|
||||||
|
- This is the recommended approach for Gitea Actions
|
||||||
|
|
||||||
|
**Why the CI image approach doesn't work:**
|
||||||
|
1. Dockerfile.ci installs dependencies in `/app`
|
||||||
|
2. Gitea Actions mounts workspace at `/workspace/david/euchre_camp`
|
||||||
|
3. Workspace mount hides the `/app` directory
|
||||||
|
4. Symlinks from `/app/node_modules` don't work because `/app` is hidden
|
||||||
|
|
||||||
|
**Alternative for performance:**
|
||||||
|
If CI performance becomes an issue, consider:
|
||||||
|
- Using GitHub Actions cache for node_modules
|
||||||
|
- Using a self-hosted runner with persistent workspace
|
||||||
|
- Using the main Dockerfile's `test-runner` target for release workflows (which works because it builds a complete image)
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
The project uses Gitea Actions for continuous integration:
|
||||||
|
|
||||||
|
**PR Workflow** (`.gitea/workflows/pr.yml`):
|
||||||
|
- Runs on pull requests to main
|
||||||
|
- Executes unit tests and acceptance tests with SQLite
|
||||||
|
- Analyzes commits for semantic versioning
|
||||||
|
- Comments suggested bump type on PRs
|
||||||
|
|
||||||
|
**Test Workflow** (`.gitea/workflows/test.yml`):
|
||||||
|
- Runs on all branch pushes
|
||||||
|
- Executes unit tests for quick feedback
|
||||||
|
- Skips auto-generated version bump commits
|
||||||
|
|
||||||
|
**Release Workflow** (`.gitea/workflows/release.yml`):
|
||||||
|
- Runs on main branch pushes
|
||||||
|
- Determines version bump type
|
||||||
|
- Bumps version and creates git tags
|
||||||
|
- Builds Docker images and runs tests
|
||||||
|
- Pushes to registry and deploys
|
||||||
|
|
||||||
|
**Database Strategy**:
|
||||||
|
- CI tests use SQLite (fast, no server required)
|
||||||
|
- Production uses PostgreSQL
|
||||||
|
- Switch with `DATABASE_PROVIDER` environment variable
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -170,6 +276,10 @@ npm run db:setup-postgres
|
|||||||
- Utilities: camelCase (e.g., `elo-utils.ts`)
|
- Utilities: camelCase (e.g., `elo-utils.ts`)
|
||||||
- Tests: `.test.ts` or `.test.tsx` suffix
|
- Tests: `.test.ts` or `.test.tsx` suffix
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
See [docs/FILE_ORGANIZATION.md](docs/FILE_ORGANIZATION.md) for detailed file organization and structure.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- **Better Auth Docs**: https://better-auth.com/docs
|
- **Better Auth Docs**: https://better-auth.com/docs
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
## [0.1.4] - 2026-04-02
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- ci: remove acceptance tests and add dev deployment
|
||||||
|
- fix(tests): resolve password validation, csv upload, and admin auth issues
|
||||||
|
- fix(ci): correct playwright config paths and add env example files
|
||||||
|
- fix(ci): use existing PostgreSQL server at dhg.lol
|
||||||
|
- fix(ci): use PostgreSQL in acceptance-tests with dev credentials
|
||||||
|
- feat(ci): build custom Docker images for Gitea Actions compatibility
|
||||||
|
- fix(tests): add @prisma/client mock for test isolation
|
||||||
|
- fix(ci): add prisma generate step to unit-tests job
|
||||||
|
- fix: downgrade ESLint to v8.57.1 for LSP compatibility
|
||||||
|
- fix: improve TypeScript types for better IDE code hinting
|
||||||
|
- feat: migrate to ESLint flat config (eslint.config.js)
|
||||||
|
- Revert "fix: downgrade ESLint to v8.x for .eslintrc.json compatibility"
|
||||||
|
- fix: downgrade ESLint to v8.x for .eslintrc.json compatibility
|
||||||
|
- fix: update PR workflow to exclude e2e tests from unit test phase
|
||||||
|
- fix: avoid global mock clearing in EditTournamentForm tests
|
||||||
|
- fix: avoid global mock clearing in Navigation tests
|
||||||
|
- fix: add DOM cleanup to bun-setup.ts
|
||||||
|
- fix: disable test isolation in bunfig.toml
|
||||||
|
- refactor: improve test structure for Bun compatibility
|
||||||
|
- feat: update CI/CD to use Bun
|
||||||
|
- feat: migrate from npm to Bun
|
||||||
|
- docs: update TODO list with recent fix
|
||||||
|
- fix: add defensive checks to prisma.ts to prevent build failures
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-04-01
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: skip release steps if no version bump commit was made
|
||||||
|
- fix: improve error handling in getCommitsSinceLastTag
|
||||||
|
- fix: add tag existence check in release workflow
|
||||||
|
- fix: resolve release workflow version bump issues
|
||||||
|
- ci-image-improvements (#18)
|
||||||
|
- fix: version bumping and Docker registry authentication (#17)
|
||||||
|
- fix: handle Docker registry authentication gracefully in release workflow
|
||||||
|
- fix: run unit tests on branch commits, skip main branch
|
||||||
|
- feat: add test workflow for every commit
|
||||||
|
- trigger: release with test-capable image
|
||||||
|
- feat: build test-capable image, run tests, then build production image
|
||||||
|
- trigger: release workflow
|
||||||
|
- fix: release workflow should not commit, only tag
|
||||||
|
- trigger: manual workflow trigger for docker.notsosm.art
|
||||||
|
- fix: update Docker build script to use docker.notsosm.art registry
|
||||||
|
- fix: update workflow to use docker.notsosm.art registry
|
||||||
|
- trigger: manual workflow trigger
|
||||||
|
- fix: update workflow to use correct Docker registries
|
||||||
|
- feat: add Gitea Actions release workflow
|
||||||
|
- feat: add view match link to admin matches page
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-04-01
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: handle Docker registry authentication gracefully in release workflow
|
||||||
|
- fix: run unit tests on branch commits, skip main branch
|
||||||
|
- feat: add test workflow for every commit
|
||||||
|
- trigger: release with test-capable image
|
||||||
|
- feat: build test-capable image, run tests, then build production image
|
||||||
|
- trigger: release workflow
|
||||||
|
- fix: release workflow should not commit, only tag
|
||||||
|
- trigger: manual workflow trigger for docker.notsosm.art
|
||||||
|
- fix: update Docker build script to use docker.notsosm.art registry
|
||||||
|
- fix: update workflow to use docker.notsosm.art registry
|
||||||
|
- trigger: manual workflow trigger
|
||||||
|
- fix: update workflow to use correct Docker registries
|
||||||
|
- feat: add Gitea Actions release workflow
|
||||||
|
- feat: add view match link to admin matches page
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-04-01
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Merge branch 'feat/database-test-safety' of ssh://git.notsosm.art/david/euchre_camp
|
||||||
|
- fix: use ELO change instead of win rate for best partner calculation
|
||||||
|
- fix: remove hardcoded passwords from docker-compose generation
|
||||||
|
- fix: load .env.development in test setup files
|
||||||
|
- fix: remove hardcoded database URLs and use environment variables
|
||||||
|
- fix: set DATABASE_URL for acceptance tests
|
||||||
|
- feat: add database test safety configuration
|
||||||
|
- fix: allowTies not saved when editing tournaments (closes #6)
|
||||||
|
- docs: add development database and testing documentation
|
||||||
|
- feat: set up development database and test cleanup utilities
|
||||||
|
- fix: improve availablePlayers query to handle undefined playerId
|
||||||
|
- fix: handle empty playerId string in user edit API
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Authentication System**: Complete Better Auth integration with session management
|
||||||
|
- **Authorization (RBAC)**: Role-based access control with player, tournament_admin, club_admin, and site_admin roles
|
||||||
|
- **Tournament Management**: Full CRUD operations with multiple formats (round-robin, single/double elimination, Swiss)
|
||||||
|
- **Rating Systems**: Support for Elo, Glicko2, and OpenSkill rating calculations
|
||||||
|
- **Admin Panel**: Comprehensive admin interface for managing players, tournaments, and matches
|
||||||
|
- **Player Profiles**: Detailed player statistics with partnership analytics
|
||||||
|
- **Match Recording**: CSV upload functionality for batch match entry
|
||||||
|
- **Development Database**: Isolated development database for testing
|
||||||
|
- **Test Cleanup System**: Automatic cleanup of test records
|
||||||
|
- **Production Database Cleanup**: Tools to remove test data while preserving real data
|
||||||
|
- **Semantic Versioning**: Git tagging and Docker image tagging system
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Session Refresh**: Fixed navigation not updating immediately after login (closes #6)
|
||||||
|
- **allowTies Save Bug**: Fixed "Allow Ties" checkbox not being saved when editing tournaments
|
||||||
|
- **Next.js 16 Compatibility**: Fixed params Promise handling across all API routes and pages
|
||||||
|
- **Match Diagram Positioning**: Fixed player positioning in match diagrams
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Database**: Migrated from SQLite to PostgreSQL with production-ready configuration
|
||||||
|
- **Docker**: Updated image tagging to use versioned tags (e.g., v0.1.0, latest)
|
||||||
|
- **Testing**: Improved test coverage with 103 passing unit tests
|
||||||
|
- **Documentation**: Added comprehensive README with development setup instructions
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
## [0.0.1] - Initial Development
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Basic database schema for Euchre tournament management
|
||||||
|
- Initial Next.js application structure
|
||||||
|
- Basic navigation and layout
|
||||||
|
- Player rankings page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
This project follows semantic versioning (Semver):
|
||||||
|
|
||||||
|
1. **Version Format**: `MAJOR.MINOR.PATCH`
|
||||||
|
- `MAJOR`: Breaking changes (API changes, database migrations requiring data migration)
|
||||||
|
- `MINOR`: New features (backward compatible)
|
||||||
|
- `PATCH`: Bug fixes, security updates
|
||||||
|
|
||||||
|
2. **Pre-release Versions**: `MAJOR.MINOR.PATCH-alpha.N`, `MAJOR.MINOR.PATCH-beta.N`, `MAJOR.MINOR.PATCH-rc.N`
|
||||||
|
|
||||||
|
3. **Creating a Release**:
|
||||||
|
```bash
|
||||||
|
# Bump version
|
||||||
|
npm run version:patch # or version:minor, version:major
|
||||||
|
|
||||||
|
# Create git tag
|
||||||
|
git tag -a v0.1.0 -m "Release v0.1.0"
|
||||||
|
|
||||||
|
# Push changes and tag
|
||||||
|
git push origin main
|
||||||
|
git push origin v0.1.0
|
||||||
|
|
||||||
|
# Build and push Docker images
|
||||||
|
npm run docker:build:push
|
||||||
|
|
||||||
|
# Create release in Gitea
|
||||||
|
tea release create v0.1.0 --title "Release v0.1.0" --note-file CHANGELOG.md
|
||||||
|
```
|
||||||
+32
-11
@@ -1,9 +1,9 @@
|
|||||||
# Multi-stage build for EuchreCamp Next.js application
|
# Multi-stage build for EuchreCamp Next.js application
|
||||||
|
|
||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM node:20-alpine AS builder
|
FROM oven/bun:alpine AS builder
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (needed for native modules)
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
@@ -13,21 +13,42 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies (including dev dependencies for building)
|
# Install dependencies (including dev dependencies for building)
|
||||||
RUN npm ci
|
RUN bun install
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client (with dummy PostgreSQL DATABASE_URL for build-time generation)
|
# Generate Prisma client (with dummy PostgreSQL DATABASE_URL for build-time generation)
|
||||||
# Note: A dummy URL is used since the real database is not available during build
|
# Note: A dummy URL is used since the real database is not available during build
|
||||||
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" npx prisma generate
|
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" bun x prisma generate
|
||||||
|
|
||||||
# Build the application (with dummy DATABASE_URL for static page generation and git commit)
|
# Build the application (with dummy DATABASE_URL for static page generation and git commit)
|
||||||
ARG GIT_COMMIT=unknown
|
ARG GIT_COMMIT=unknown
|
||||||
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" NEXT_PUBLIC_GIT_COMMIT=$GIT_COMMIT npm run build
|
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" NEXT_PUBLIC_GIT_COMMIT=$GIT_COMMIT bun run build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Test runner (includes dev dependencies for testing)
|
||||||
FROM node:20-alpine AS runner
|
FROM oven/bun:alpine AS test-runner
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache python3 make g++ git
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install ALL dependencies (including dev dependencies for testing)
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" bun x prisma generate
|
||||||
|
|
||||||
|
# Stage 3: Production runner
|
||||||
|
FROM oven/bun:alpine AS runner
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling
|
# Install dumb-init for proper signal handling
|
||||||
RUN apk add --no-cache dumb-init
|
RUN apk add --no-cache dumb-init
|
||||||
@@ -43,15 +64,15 @@ WORKDIR /app
|
|||||||
COPY --from=builder --chown=euchre:euchre /app/.next ./.next
|
COPY --from=builder --chown=euchre:euchre /app/.next ./.next
|
||||||
COPY --from=builder --chown=euchre:euchre /app/public ./public
|
COPY --from=builder --chown=euchre:euchre /app/public ./public
|
||||||
COPY --from=builder --chown=euchre:euchre /app/package.json ./package.json
|
COPY --from=builder --chown=euchre:euchre /app/package.json ./package.json
|
||||||
COPY --from=builder --chown=euchre:euchre /app/package-lock.json ./package-lock.json
|
COPY --from=builder --chown=euchre:euchre /app/bun.lockb ./bun.lockb
|
||||||
COPY --from=builder --chown=euchre:euchre /app/prisma ./prisma
|
COPY --from=builder --chown=euchre:euchre /app/prisma ./prisma
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
RUN npm ci --omit=dev
|
RUN bun install --production
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
# Note: We need to set DATABASE_URL even for generation because prisma.config.ts requires it
|
# Note: We need to set DATABASE_URL even for generation because prisma.config.ts requires it
|
||||||
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" npx prisma generate
|
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" bun x prisma generate
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER euchre
|
USER euchre
|
||||||
@@ -65,4 +86,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
|
|
||||||
# Start command
|
# Start command
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["npm", "start"]
|
CMD ["bun", "run", "start"]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Base CI image with Bun, Node.js, Playwright, and build tools
|
||||||
|
# Used for Gitea Actions CI workflows
|
||||||
|
# Uses Microsoft Playwright image as base (Ubuntu-based) with Bun added
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.58.0-jammy AS base
|
||||||
|
|
||||||
|
# Install unzip (required for Bun installation) and other tools
|
||||||
|
RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Bun (latest version)
|
||||||
|
# Note: The playwright image already has Node.js pre-installed
|
||||||
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Add Bun to PATH for subsequent commands
|
||||||
|
ENV PATH="/root/.bun/bin:$PATH"
|
||||||
|
|
||||||
|
# Verify installations
|
||||||
|
RUN echo "=== Bun Version ===" && bun --version && \
|
||||||
|
echo "=== Node.js Version ===" && node --version && \
|
||||||
|
echo "=== Playwright Version ===" && bun x playwright --version
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set default environment variables
|
||||||
|
ENV DATABASE_PROVIDER=sqlite
|
||||||
|
ENV DATABASE_URL=file:./prisma/ci.db
|
||||||
|
ENV BETTER_AUTH_SECRET=test-secret-key-for-ci-only
|
||||||
|
ENV NODE_ENV=test
|
||||||
|
|
||||||
|
# Health check command (can be overridden)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD bun --version
|
||||||
@@ -17,20 +17,29 @@ EuchreCamp is a full-stack web application built with Next.js 14+ and TypeScript
|
|||||||
|
|
||||||
- **Framework**: Next.js 14+ (App Router)
|
- **Framework**: Next.js 14+ (App Router)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Database**: Prisma ORM with SQLite
|
- **Database**: Prisma ORM with SQLite (default) or PostgreSQL
|
||||||
- **Styling**: Tailwind CSS
|
- **Styling**: Tailwind CSS
|
||||||
- **Authentication**: Better Auth
|
- **Authentication**: Better Auth
|
||||||
- **Form Handling**: React Hook Form + Zod validation
|
- **Form Handling**: React Hook Form + Zod validation
|
||||||
- **CSV Parsing**: PapaParse
|
- **CSV Parsing**: PapaParse
|
||||||
- **Unit Testing**: Vitest
|
- **Unit Testing**: Vitest
|
||||||
- **Acceptance Testing**: Playwright
|
- **Acceptance Testing**: Playwright
|
||||||
|
- **CI/CD**: Gitea Actions with SQLite for CI tests
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
euchre_camp/
|
euchre_camp/
|
||||||
├── src/
|
├── .gitea/workflows/ # CI/CD workflows (Gitea Actions)
|
||||||
│ ├── app/
|
├── docs/ # Documentation
|
||||||
|
│ ├── deployment/ # Deployment guides
|
||||||
|
│ └── planning/ # Planning documents
|
||||||
|
├── prisma/ # Prisma schema and migrations
|
||||||
|
├── public/ # Static assets
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
│ └── python/ # Python scripts (legacy)
|
||||||
|
├── src/ # Source code
|
||||||
|
│ ├── app/ # Next.js app directory
|
||||||
│ │ ├── api/ # API routes
|
│ │ ├── api/ # API routes
|
||||||
│ │ ├── auth/ # Authentication pages
|
│ │ ├── auth/ # Authentication pages
|
||||||
│ │ ├── admin/ # Admin pages
|
│ │ ├── admin/ # Admin pages
|
||||||
@@ -39,16 +48,15 @@ euchre_camp/
|
|||||||
│ │ └── components/ # Shared components
|
│ │ └── components/ # Shared components
|
||||||
│ ├── lib/ # Utilities and configuration
|
│ ├── lib/ # Utilities and configuration
|
||||||
│ │ ├── auth.ts # Better Auth configuration
|
│ │ ├── auth.ts # Better Auth configuration
|
||||||
│ │ ├── prisma.ts # Prisma client
|
│ │ ├── prisma.ts # Prisma client (SQLite/PostgreSQL)
|
||||||
│ │ ├── permissions.ts # Authorization functions
|
│ │ ├── permissions.ts # Authorization functions
|
||||||
│ │ └── elo-utils.ts # Elo calculation utilities
|
│ │ └── elo-utils.ts # Elo calculation utilities
|
||||||
│ └── __tests__/ # Vitest and Playwright tests
|
│ └── __tests__/ # Vitest and Playwright tests
|
||||||
├── prisma/ # Prisma schema and migrations
|
└── ... # Configuration files in root
|
||||||
├── docs/ # Documentation
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
└── public/ # Static assets
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [docs/FILE_ORGANIZATION.md](docs/FILE_ORGANIZATION.md) for detailed file organization.
|
||||||
|
|
||||||
## Features Implemented
|
## Features Implemented
|
||||||
|
|
||||||
### Epic 1: Authentication & User Management
|
### Epic 1: Authentication & User Management
|
||||||
@@ -161,6 +169,46 @@ DATABASE_URL="postgresql://username:password@localhost:5432/euchre_camp"
|
|||||||
DATABASE_SHADOW_URL="postgresql://username:password@localhost:5432/euchre_camp_shadow"
|
DATABASE_SHADOW_URL="postgresql://username:password@localhost:5432/euchre_camp_shadow"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development Database
|
||||||
|
|
||||||
|
For development and testing, use a separate development database to avoid conflicts with production data.
|
||||||
|
|
||||||
|
**Setup Development Database:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create and setup the development database
|
||||||
|
npm run db:setup-dev
|
||||||
|
|
||||||
|
# Reset the development database (drops and recreates)
|
||||||
|
npm run db:reset-dev
|
||||||
|
|
||||||
|
# Clean development database (drop and recreate with migrations)
|
||||||
|
npm run db:setup-dev:clean
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Configuration:**
|
||||||
|
|
||||||
|
The development database uses `.env.development` which is automatically configured for:
|
||||||
|
- Database: `euchre_camp_dev`
|
||||||
|
- NODE_ENV: `development`
|
||||||
|
|
||||||
|
**Testing with Development Database:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests (uses test database automatically)
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Run acceptance tests (uses development database)
|
||||||
|
npm run test:acceptance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Data Cleanup:**
|
||||||
|
|
||||||
|
All tests automatically clean up their created records:
|
||||||
|
- Global setup/teardown handles Playwright tests
|
||||||
|
- Test utilities provide `cleanupTestRecords()` for manual cleanup
|
||||||
|
- Tests use `beforeEach` and `afterEach` hooks for automatic cleanup
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Creating a Tournament
|
### Creating a Tournament
|
||||||
@@ -202,6 +250,36 @@ Visit `/` to see:
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
### Using just (recommended)
|
||||||
|
The project includes a `justfile` with common development tasks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show all available tasks
|
||||||
|
just help
|
||||||
|
|
||||||
|
# Development mode
|
||||||
|
just dev
|
||||||
|
|
||||||
|
# Run all tests (unit + acceptance with SQLite)
|
||||||
|
just test
|
||||||
|
|
||||||
|
# Run PR validation (what runs on pull requests)
|
||||||
|
just pr-validate
|
||||||
|
|
||||||
|
# Run CI pipeline locally
|
||||||
|
just ci
|
||||||
|
|
||||||
|
# Switch database provider
|
||||||
|
just db-switch-sqlite
|
||||||
|
just db-switch-postgres
|
||||||
|
|
||||||
|
# Docker shortcuts
|
||||||
|
just docker-up
|
||||||
|
just docker-down
|
||||||
|
just docker-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using npm scripts directly
|
||||||
```bash
|
```bash
|
||||||
# Development mode
|
# Development mode
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -215,6 +293,9 @@ npm run test
|
|||||||
|
|
||||||
# Run acceptance tests
|
# Run acceptance tests
|
||||||
npm run test:acceptance
|
npm run test:acceptance
|
||||||
|
|
||||||
|
# Run acceptance tests with SQLite (CI-style)
|
||||||
|
DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Commands
|
### Database Commands
|
||||||
@@ -273,6 +354,56 @@ User stories are organized into epics in `docs/USER_STORIES.md`:
|
|||||||
7. Mobile Responsiveness
|
7. Mobile Responsiveness
|
||||||
8. Data Management & Export
|
8. Data Management & Export
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
The application uses Gitea Actions for continuous integration and deployment:
|
||||||
|
|
||||||
|
### Workflow Architecture
|
||||||
|
1. **PR Workflow** (`.gitea/workflows/pr.yml`): Runs on pull requests
|
||||||
|
- Unit tests (fast feedback)
|
||||||
|
- Acceptance tests with SQLite database
|
||||||
|
- Semantic version bump analysis
|
||||||
|
|
||||||
|
2. **Test Workflow** (`.gitea/workflows/test.yml`): Runs on all branch pushes
|
||||||
|
- Unit tests for quick feedback
|
||||||
|
- Skips auto-generated version bumps
|
||||||
|
|
||||||
|
3. **Release Workflow** (`.gitea/workflows/release.yml`): Runs on main branch pushes
|
||||||
|
- Version bumping and tagging
|
||||||
|
- Docker image building and testing
|
||||||
|
- Registry push and deployment
|
||||||
|
|
||||||
|
### CI Runner Image
|
||||||
|
|
||||||
|
**Note:** The CI runner image approach has been deprecated for Gitea Actions workflows.
|
||||||
|
|
||||||
|
The original attempt to use a pre-built CI runner image with pre-installed dependencies encountered fundamental issues with how Gitea Actions handles workspace mounting. When Gitea Actions runs a container job, it mounts the workspace at a specific path (e.g., `/workspace/david/euchre_camp`), which hides the container's `/app` directory where dependencies were installed.
|
||||||
|
|
||||||
|
**Current Approach:**
|
||||||
|
- Workflows use standard `node:20-alpine` or `mcr.microsoft.com/playwright` containers
|
||||||
|
- Dependencies are installed via `npm ci` in each workflow run
|
||||||
|
- This is the recommended approach for Gitea Actions
|
||||||
|
|
||||||
|
**Why the CI image approach doesn't work:**
|
||||||
|
1. Dockerfile.ci installs dependencies in `/app`
|
||||||
|
2. Gitea Actions mounts workspace at `/workspace/david/euchre_camp`
|
||||||
|
3. Workspace mount hides the `/app` directory
|
||||||
|
4. Symlinks from `/app/node_modules` don't work because `/app` is hidden
|
||||||
|
|
||||||
|
### Database Strategy for CI
|
||||||
|
- **CI Tests**: SQLite database (fast, no server required)
|
||||||
|
- **Production**: PostgreSQL (production-like environment)
|
||||||
|
- **Configuration**: `DATABASE_PROVIDER` environment variable
|
||||||
|
|
||||||
|
### Running CI Locally
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
This application can be run using Docker and Docker Compose. See [DOCKER.md](DOCKER.md) for detailed instructions.
|
This application can be run using Docker and Docker Compose. See [DOCKER.md](DOCKER.md) for detailed instructions.
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
# EuchreCamp - Todo List
|
|
||||||
|
|
||||||
## Current Tasks
|
|
||||||
|
|
||||||
### Completed ✅
|
|
||||||
- [x] Add `site_admin` role to database schema and permissions system
|
|
||||||
- [x] Add `isCasual` boolean field to Match model (already existed)
|
|
||||||
- [x] Update match upload API to support casual matches
|
|
||||||
- [x] Update match upload UI to include casual checkbox
|
|
||||||
- [x] Add tournament deletion API endpoint with delete/orphan options
|
|
||||||
- [x] Add delete tournament button and modal to tournament detail page
|
|
||||||
- [x] Run tests and verify implementation (84 tests passing)
|
|
||||||
- [x] Fix session issues with tournament admin access
|
|
||||||
- [x] Fix Elo recalculation error for player merge (delete elo snapshots before deleting players)
|
|
||||||
- [x] Add admin player management page
|
|
||||||
- [x] Add player name editing functionality in admin UI
|
|
||||||
- [x] Add admin panel links to navigation header
|
|
||||||
- [x] Add tournament update API endpoint (PUT /api/tournaments/[id])
|
|
||||||
- [x] Consolidate delete endpoint from admin API to main tournaments API
|
|
||||||
- [x] Update database schema to add variant scoring fields (targetScore, allowTies)
|
|
||||||
- [x] Fix tie handling logic in partnership stats (ties now correctly tracked)
|
|
||||||
- [x] Fix test files for normalizedName field in Player model
|
|
||||||
- [x] Fix auth.ts to include normalizedName in Player creation
|
|
||||||
- [x] Write TODO list to repository file
|
|
||||||
- [x] Auto-create tournament when uploading matches without selecting one
|
|
||||||
|
|
||||||
### In Progress 🔄
|
|
||||||
- [ ] Update API routes to handle new variant scoring fields
|
|
||||||
- [ ] Update EditTournamentForm to add variant scoring controls
|
|
||||||
- [ ] Update MatchEditor to use tournament-specific target score
|
|
||||||
- [ ] Run tests and verify variant scoring implementation
|
|
||||||
|
|
||||||
### Recently Completed ✅
|
|
||||||
- [x] Add OpenSkill rating system support (src/lib/openskill-utils.ts)
|
|
||||||
- [x] Add Glicko2 rating system support (src/lib/glicko2-utils.ts)
|
|
||||||
- [x] Reset database and run all migrations from scratch
|
|
||||||
- [x] Regenerate Prisma client with new rating models
|
|
||||||
- [x] Update match upload page to auto-create tournament if none selected
|
|
||||||
- [x] Update all admin scripts to use PrismaPg adapter and dotenv
|
|
||||||
- [x] Fix match diagram player positioning
|
|
||||||
- [x] Add CasaOS deployment configuration and documentation
|
|
||||||
- [x] Create migration to add rating system tables (elo_ratings, glicko2_ratings, open_skill_ratings)
|
|
||||||
- [x] Add tabbed rankings page to display Elo, OpenSkill, and Glicko2 ratings
|
|
||||||
|
|
||||||
### Backlog 📋
|
|
||||||
- [ ] Add UI controls for variant scoring in tournament creation/edit
|
|
||||||
- [ ] Test variant tournament functionality end-to-end
|
|
||||||
- [ ] Add validation for tie scores based on tournament configuration
|
|
||||||
- [ ] Document variant tournament features
|
|
||||||
|
|
||||||
## Recently Completed (Detailed)
|
|
||||||
|
|
||||||
### Variant Euchre Scoring Support
|
|
||||||
- Added `targetScore` and `allowTies` fields to Event model
|
|
||||||
- Created database migration for new fields
|
|
||||||
- Fixed partnership stats tie handling (ties now increment neither wins nor losses)
|
|
||||||
- Updated Elo calculation functions to handle ties correctly (0.5 points for draw)
|
|
||||||
|
|
||||||
### Tournament Deletion
|
|
||||||
- Consolidated delete endpoint to `/api/tournaments/[id]`
|
|
||||||
- Added options to delete matches or orphan them
|
|
||||||
- Updated DeleteTournamentButton to use consolidated endpoint
|
|
||||||
|
|
||||||
### Player Management
|
|
||||||
- Added admin players page at `/admin/players`
|
|
||||||
- Added player name editing functionality via PATCH endpoint
|
|
||||||
- Added player merge functionality with automatic Elo recalculation
|
|
||||||
- Fixed foreign key constraint issues with elo_snapshots
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
- Added `site_admin` role as highest privilege level
|
|
||||||
- Updated all permission functions to include site_admin support
|
|
||||||
- Fixed session cache issues by reading roles from database
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- All 84 unit tests passing
|
|
||||||
- Database migrations applied successfully
|
|
||||||
- TypeScript compilation has pre-existing errors unrelated to our changes
|
|
||||||
|
|
||||||
### Completed After Commit 1729dac
|
|
||||||
|
|
||||||
#### Next.js 16 Breaking Change Fixes
|
|
||||||
- [x] Fixed `params.id` usage in all page components (must use `await params`)
|
|
||||||
- [x] Fixed `params.id` usage in all API routes (must use `await params`)
|
|
||||||
- [x] Updated client components to use `Promise<{ id: string }>` type
|
|
||||||
- [x] Added regression tests for Next.js 16 params Promise handling
|
|
||||||
- [x] Verified all 100 unit tests pass
|
|
||||||
|
|
||||||
#### Files Updated:
|
|
||||||
- Player pages: `profile.tsx`, `schedule.tsx`
|
|
||||||
- Tournament pages: `page.tsx`, `results.tsx`, `edit.tsx`, `entry.tsx`
|
|
||||||
- API routes: `admin/players/[id]/route.ts`, `users/[id]/route.ts`, `users/[id]/role/route.ts`
|
|
||||||
- Tournament API routes: `[id]/route.ts`, `[id]/participants/route.ts`, `[id]/games/bulk/route.ts`
|
|
||||||
|
|
||||||
#### Root Cause
|
|
||||||
Next.js 16 requires `params` to be awaited in both server components and API routes:
|
|
||||||
- Before: `const { id } = params`
|
|
||||||
- After: `const { id } = await params`
|
|
||||||
|
|
||||||
This was not caught by the unit test suite because:
|
|
||||||
- Unit tests test individual functions in isolation
|
|
||||||
- E2E tests (Playwright) would catch this but weren't run after the upgrade
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./src/__tests__/bun-setup.ts"]
|
||||||
|
exclude = ["e2e/**", "**/e2e/**"]
|
||||||
|
# isolation = true
|
||||||
@@ -7,15 +7,15 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: dhg.lol:5000/euchre-camp:0.1.0
|
image: euchre-camp/euchre-camp:0.1.0.dev
|
||||||
container_name: euchre-camp
|
container_name: euchre-camp
|
||||||
ports:
|
ports:
|
||||||
- "51193:3000"
|
- "51193:3000"
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration (REQUIRED: Set via CasaOS environment variables)
|
# Database Configuration (REQUIRED: Set via CasaOS environment variables)
|
||||||
- DATABASE_PROVIDER=postgresql
|
- DATABASE_PROVIDER=postgresql
|
||||||
- DATABASE_URL="postgresql://euchre_camp:LINGO5row_hiding@dhg.lol:5432/euchre_camp"
|
- DATABASE_URL=${DATABASE_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp}
|
||||||
- DATABASE_SHADOW_URL="postgresql://euchre_camp:LINGO5row_hiding@dhg.lol:5432/euchre_camp_shadow"
|
- DATABASE_SHADOW_URL=${DATABASE_SHADOW_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp_shadow}
|
||||||
|
|
||||||
# Better Auth Configuration
|
# Better Auth Configuration
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
||||||
|
|
||||||
# Version tracking
|
# Version tracking
|
||||||
- IMAGE_VERSION=0.1.0
|
- IMAGE_VERSION=0.1.0.dev
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Persist uploaded files and static content
|
# Persist uploaded files and static content
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
- DATABASE_PROVIDER=postgresql
|
- DATABASE_PROVIDER=postgresql
|
||||||
- DATABASE_URL=postgresql://euchre_camp:password@db:5432/euchre_camp
|
- DATABASE_URL=${DATABASE_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp}
|
||||||
- DATABASE_SHADOW_URL=postgresql://euchre_camp:password@db:5432/euchre_camp_shadow
|
- DATABASE_SHADOW_URL=${DATABASE_SHADOW_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp_shadow}
|
||||||
|
|
||||||
# Better Auth Configuration
|
# Better Auth Configuration
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-dev-secret-change-in-production}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-dev-secret-change-in-production}
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
||||||
|
|
||||||
# Version tracking
|
# Version tracking
|
||||||
- IMAGE_VERSION=0.1.0
|
- IMAGE_VERSION=0.1.0.dev-dev
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Mount source code for hot reload
|
# Mount source code for hot reload
|
||||||
|
|||||||
+4
-4
@@ -3,15 +3,15 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: dhg.lol:5000/euchre-camp:0.1.0
|
image: docker.notsosm.art/euchre-camp:0.1.0.dev
|
||||||
container_name: euchre-camp-app
|
container_name: euchre-camp-app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
- DATABASE_PROVIDER=postgresql
|
- DATABASE_PROVIDER=postgresql
|
||||||
- DATABASE_URL=postgresql://euchre_camp:password@db:5432/euchre_camp
|
- DATABASE_URL=${DATABASE_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp}
|
||||||
- DATABASE_SHADOW_URL=postgresql://euchre_camp:password@db:5432/euchre_camp_shadow
|
- DATABASE_SHADOW_URL=${DATABASE_SHADOW_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp_shadow}
|
||||||
|
|
||||||
# Better Auth Configuration
|
# Better Auth Configuration
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
||||||
|
|
||||||
# Version tracking
|
# Version tracking
|
||||||
- IMAGE_VERSION=0.1.0
|
- IMAGE_VERSION=0.1.0.dev
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# File Organization
|
||||||
|
|
||||||
|
This document describes the organization of files in the EuchreCamp project.
|
||||||
|
|
||||||
|
## Root Directory
|
||||||
|
|
||||||
|
### Essential Files (Keep in Root)
|
||||||
|
- `README.md` - Main project documentation
|
||||||
|
- `AGENTS.md` - AI agent guide
|
||||||
|
- `CHANGELOG.md` - Version changelog
|
||||||
|
- `package.json` - Node.js dependencies and scripts
|
||||||
|
- `package-lock.json` - Dependency lock file
|
||||||
|
- `tsconfig.json` - TypeScript configuration
|
||||||
|
- `next.config.js` - Next.js configuration
|
||||||
|
- `.gitignore` - Git ignore file
|
||||||
|
- `Dockerfile` - Docker build configuration
|
||||||
|
- `justfile` - Development task automation
|
||||||
|
|
||||||
|
### Configuration Files (Keep in Root)
|
||||||
|
- `.eslintrc.json` - ESLint configuration
|
||||||
|
- `postcss.config.mjs` - PostCSS configuration
|
||||||
|
- `playwright.config.ts` - Playwright test configuration
|
||||||
|
- `vitest.config.mts` - Vitest configuration
|
||||||
|
- `vitest.setup.ts` - Vitest setup
|
||||||
|
- `.dockerignore` - Docker ignore file
|
||||||
|
- `mise.toml` - Mise version manager config
|
||||||
|
|
||||||
|
### Docker Files (Keep in Root)
|
||||||
|
- `docker-compose.yml` - Main Docker Compose
|
||||||
|
- `docker-compose.dev.yml` - Development Docker Compose
|
||||||
|
- `docker-compose.override.yml` - Override for dev
|
||||||
|
- `docker-compose.casaos.yml` - CasaOS specific
|
||||||
|
|
||||||
|
### Environment Files (Keep in Root, Gitignored)
|
||||||
|
- `.env` - Environment variables
|
||||||
|
- `.env.development` - Development environment
|
||||||
|
|
||||||
|
## Organized Directories
|
||||||
|
|
||||||
|
### `.gitea/` - Gitea Actions Workflows
|
||||||
|
- `workflows/pr.yml` - Pull request workflow (unit + acceptance tests)
|
||||||
|
- `workflows/test.yml` - Test workflow (unit tests on branch pushes)
|
||||||
|
- `workflows/release.yml` - Release workflow (version bump + Docker build)
|
||||||
|
- `WORKFLOW_ARCHITECTURE.md` - Workflow architecture documentation
|
||||||
|
|
||||||
|
### `docs/` - Documentation
|
||||||
|
- `deployment/` - Deployment documentation
|
||||||
|
- `CASAOS_DEPLOYMENT.md` - CasaOS deployment guide
|
||||||
|
- `DOCKER.md` - Docker deployment instructions
|
||||||
|
- `TODO.md` - Project TODO list (in docs root for visibility)
|
||||||
|
- `USER_STORIES.md` - User stories organized by epic
|
||||||
|
- Other documentation files (design, implementation, testing, etc.)
|
||||||
|
|
||||||
|
### `scripts/` - Utility Scripts
|
||||||
|
- `python/` - Python scripts (legacy/old functionality)
|
||||||
|
- `generate_games.py` - Generate sample games
|
||||||
|
- `update_partnership_stats.py` - Update partnership stats
|
||||||
|
- `update_player_stats.py` - Update player stats
|
||||||
|
- `bump-version.js` - Version bumping script
|
||||||
|
- `build-and-push-docker.js` - Docker build and push script
|
||||||
|
- `switch-database.js` - Database provider switching
|
||||||
|
- `create-admin-via-api.js` - Admin user creation via API
|
||||||
|
- `create-admin-better-auth.js` - Admin user creation via database
|
||||||
|
- `list-users.js` - List all users
|
||||||
|
- `update-admin-password.js` - Update admin password
|
||||||
|
- `seed.js` - Database seeding
|
||||||
|
- And other Node.js scripts...
|
||||||
|
|
||||||
|
### `src/` - Source Code
|
||||||
|
- `app/` - Next.js app directory
|
||||||
|
- `api/` - API routes
|
||||||
|
- `auth/` - Authentication pages
|
||||||
|
- `admin/` - Admin pages
|
||||||
|
- `players/` - Player pages
|
||||||
|
- `rankings/` - Rankings page
|
||||||
|
- `components/` - Shared components
|
||||||
|
- `lib/` - Utilities and configuration
|
||||||
|
- `auth.ts` - Better Auth configuration
|
||||||
|
- `prisma.ts` - Prisma client (supports SQLite and PostgreSQL)
|
||||||
|
- `permissions.ts` - Authorization functions
|
||||||
|
- `elo-utils.ts` - Elo calculation utilities
|
||||||
|
- `__tests__/` - Vitest and Playwright tests
|
||||||
|
- `unit/` - Unit tests
|
||||||
|
- `e2e/` - End-to-end acceptance tests
|
||||||
|
|
||||||
|
### `prisma/` - Database
|
||||||
|
- `schema.prisma` - Prisma schema
|
||||||
|
- `migrations/` - Database migrations
|
||||||
|
- `dev.db` - SQLite development database (if using SQLite)
|
||||||
|
|
||||||
|
### `public/` - Static Assets
|
||||||
|
- Images, fonts, and other static files
|
||||||
|
|
||||||
|
### `playwright/` - Playwright Test Data
|
||||||
|
- Authentication state files
|
||||||
|
|
||||||
|
## Generated Directories (Gitignored)
|
||||||
|
- `.next/` - Next.js build output
|
||||||
|
- `node_modules/` - Node.js dependencies
|
||||||
|
- `playwright-report/` - Playwright test reports
|
||||||
|
- `test-results/` - Test results
|
||||||
|
|
||||||
|
## File Organization Principles
|
||||||
|
|
||||||
|
1. **Keep standard files in root**: package.json, tsconfig.json, etc.
|
||||||
|
2. **Organize by function**: Group related files in directories
|
||||||
|
3. **Separate generated from source**: Keep build outputs and dependencies separate
|
||||||
|
4. **Document organization**: Use this file to explain structure
|
||||||
|
5. **Follow conventions**: Use standard naming and organization patterns
|
||||||
|
|
||||||
|
## CI/CD File Organization
|
||||||
|
|
||||||
|
### Workflows
|
||||||
|
- `.gitea/workflows/pr.yml` - Pull request validation
|
||||||
|
- `.gitea/workflows/test.yml` - Branch testing
|
||||||
|
- `.gitea/workflows/release.yml` - Main branch release
|
||||||
|
|
||||||
|
### Database Strategy
|
||||||
|
- **CI/Testing**: SQLite (fast, no server)
|
||||||
|
- **Production**: PostgreSQL (production-like)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Unit tests: `npm run test:run`
|
||||||
|
- Acceptance tests (SQLite): `DATABASE_PROVIDER=sqlite npm run test:acceptance`
|
||||||
|
- Acceptance tests (PostgreSQL): `npm run test:acceptance` (with Docker)
|
||||||
+101
-116
@@ -1,134 +1,119 @@
|
|||||||
# EuchreCamp - Project Todo List
|
# EuchreCamp - Todo List
|
||||||
|
|
||||||
## Completed Features
|
## Current Tasks
|
||||||
|
|
||||||
### Backend
|
### Completed ✅
|
||||||
- [x] Database schema for matches, players, teams, events
|
- [x] Add `site_admin` role to database schema and permissions system
|
||||||
- [x] Elo rating calculator and job
|
- [x] Add `isCasual` boolean field to Match model (already existed)
|
||||||
- [x] Partnership tracking and analytics
|
- [x] Update match upload API to support casual matches
|
||||||
- [x] Tournament generator (round-robin, single elim, double elim, Swiss)
|
- [x] Update match upload UI to include casual checkbox
|
||||||
- [x] ROM relations and repositories
|
- [x] Add tournament deletion API endpoint with delete/orphan options
|
||||||
- [x] Acceptance test suite (8 tests passing)
|
- [x] Add delete tournament button and modal to tournament detail page
|
||||||
|
- [x] Run tests and verify implementation (84 tests passing)
|
||||||
|
- [x] Fix session issues with tournament admin access
|
||||||
|
- [x] Fix Elo recalculation error for player merge (delete elo snapshots before deleting players)
|
||||||
|
- [x] Add admin player management page
|
||||||
|
- [x] Add player name editing functionality in admin UI
|
||||||
|
- [x] Add admin panel links to navigation header
|
||||||
|
- [x] Add tournament update API endpoint (PUT /api/tournaments/[id])
|
||||||
|
- [x] Consolidate delete endpoint from admin API to main tournaments API
|
||||||
|
- [x] Update database schema to add variant scoring fields (targetScore, allowTies)
|
||||||
|
- [x] Fix tie handling logic in partnership stats (ties now correctly tracked)
|
||||||
|
- [x] Fix test files for normalizedName field in Player model
|
||||||
|
- [x] Fix auth.ts to include normalizedName in Player creation
|
||||||
|
- [x] Write TODO list to repository file
|
||||||
|
- [x] Auto-create tournament when uploading matches without selecting one
|
||||||
|
|
||||||
### Frontend
|
### In Progress 🔄
|
||||||
- [x] Basic player rankings page
|
- [ ] Update API routes to handle new variant scoring fields
|
||||||
- [x] Match entry form
|
- [ ] Update EditTournamentForm to add variant scoring controls
|
||||||
|
- [ ] Update MatchEditor to use tournament-specific target score
|
||||||
|
- [ ] Run tests and verify variant scoring implementation
|
||||||
|
|
||||||
## In Progress - UI Development
|
### Recently Completed ✅
|
||||||
|
- [x] Update CI/CD workflows to use Bun (PR, release)
|
||||||
|
- [x] Update Dockerfile to use Bun Alpine image
|
||||||
|
- [x] Update PR workflow to use Bun
|
||||||
|
- [x] Update Release workflow to use Bun
|
||||||
|
- [x] Remove test.yml workflow (redundant)
|
||||||
|
- [x] Verify Docker build with Bun
|
||||||
|
|
||||||
### Completed
|
### Recently Completed ✅
|
||||||
- [x] Navigation layout (Next.js components)
|
- [x] Fix Prisma build error in CI pipeline (Docker build failure due to missing DATABASE_URL validation)
|
||||||
- [x] UI Design document (UI_DESIGN.md)
|
- [x] Add defensive checks to src/lib/prisma.ts to prevent build failures
|
||||||
- [x] Player Profile page (Next.js)
|
- [x] Migrate from npm to Bun package manager
|
||||||
- [x] Basic CSS styling (Tailwind CSS)
|
- [x] Migrate unit tests (Vitest → Bun test runner)
|
||||||
- [x] Player Schedule page (Next.js)
|
- [x] Migrate component tests (Vitest → Bun test runner)
|
||||||
- [x] Route for player schedule
|
- [x] Configure Bun with DOM environment for React Testing Library
|
||||||
|
- [x] Keep Playwright for E2E tests (hybrid approach)
|
||||||
|
|
||||||
### View Types to Implement
|
### Recently Completed ✅
|
||||||
- [ ] Tournament Admin View (Phase 2-3)
|
- [x] Add OpenSkill rating system support (src/lib/openskill-utils.ts)
|
||||||
- Create/manage tournaments
|
- [x] Add Glicko2 rating system support (src/lib/glicko2-utils.ts)
|
||||||
- Set up brackets and matchups
|
- [x] Reset database and run all migrations from scratch
|
||||||
- Record match results
|
- [x] Regenerate Prisma client with new rating models
|
||||||
- View tournament standings
|
- [x] Update match upload page to auto-create tournament if none selected
|
||||||
|
- [x] Update all admin scripts to use PrismaPg adapter and dotenv
|
||||||
|
- [x] Fix match diagram player positioning
|
||||||
|
- [x] Add CasaOS deployment configuration and documentation
|
||||||
|
- [x] Create migration to add rating system tables (elo_ratings, glicko2_ratings, open_skill_ratings)
|
||||||
|
- [x] Add tabbed rankings page to display Elo, OpenSkill, and Glicko2 ratings
|
||||||
|
|
||||||
- [ ] Club Admin View (Superuser) (Phase 3-4)
|
### Backlog 📋
|
||||||
- Manage all players
|
- [ ] Add UI controls for variant scoring in tournament creation/edit
|
||||||
- View club-wide statistics
|
- [ ] Test variant tournament functionality end-to-end
|
||||||
- Configure club settings
|
- [ ] Add validation for tie scores based on tournament configuration
|
||||||
- Manage tournaments
|
- [ ] Document variant tournament features
|
||||||
|
|
||||||
- [ ] Player Profile View (Phase 1-2)
|
## Recently Completed (Detailed)
|
||||||
- Display player info and Elo rating
|
|
||||||
- Show partnership analytics
|
|
||||||
- Display match history
|
|
||||||
- Tournament participation
|
|
||||||
- Enhance existing template
|
|
||||||
|
|
||||||
- [ ] Player Tournament Schedule View (Phase 4)
|
### Variant Euchre Scoring Support
|
||||||
- Show upcoming matches
|
- Added `targetScore` and `allowTies` fields to Event model
|
||||||
- Display tournament brackets
|
- Created database migration for new fields
|
||||||
- Record personal match results
|
- Fixed partnership stats tie handling (ties now increment neither wins nor losses)
|
||||||
|
- Updated Elo calculation functions to handle ties correctly (0.5 points for draw)
|
||||||
|
|
||||||
### UI Components Needed
|
### Tournament Deletion
|
||||||
- [x] Navigation system (role-based) - Started
|
- Consolidated delete endpoint to `/api/tournaments/[id]`
|
||||||
- [ ] Dashboard layouts
|
- Added options to delete matches or orphan them
|
||||||
- [ ] Forms for data entry
|
- Updated DeleteTournamentButton to use consolidated endpoint
|
||||||
- [ ] Tables for displaying data
|
|
||||||
- [ ] Charts for statistics
|
|
||||||
- [ ] Bracket visualization
|
|
||||||
|
|
||||||
### Implementation Phases
|
### Player Management
|
||||||
- [x] Phase 1: Navigation & Layout
|
- Added admin players page at `/admin/players`
|
||||||
- [x] Phase 2: Player Profile Enhancements
|
- Added player name editing functionality via PATCH endpoint
|
||||||
- [x] Phase 3: Tournament Admin View
|
- Added player merge functionality with automatic Elo recalculation
|
||||||
- [x] Phase 4: Club Admin View
|
- Fixed foreign key constraint issues with elo_snapshots
|
||||||
- [x] Phase 5: Player Schedule View
|
|
||||||
- [ ] Phase 6: Authentication & Authorization
|
|
||||||
- [x] Phase 7: Polish & Testing
|
|
||||||
|
|
||||||
## Future Enhancements
|
### Permissions
|
||||||
|
- Added `site_admin` role as highest privilege level
|
||||||
|
- Updated all permission functions to include site_admin support
|
||||||
|
- Fixed session cache issues by reading roles from database
|
||||||
|
|
||||||
### Features
|
## Notes
|
||||||
- [ ] Real-time match updates (WebSockets)
|
- All 84 unit tests passing
|
||||||
- [ ] Mobile-responsive design improvements
|
- Database migrations applied successfully
|
||||||
- [ ] Email notifications
|
- TypeScript compilation has pre-existing errors unrelated to our changes
|
||||||
- [ ] Import/Export functionality
|
|
||||||
- [ ] API for third-party integrations
|
|
||||||
- [ ] Advanced analytics charts
|
|
||||||
|
|
||||||
### Technical
|
### Completed After Commit 1729dac
|
||||||
- [ ] Performance optimization
|
|
||||||
- [ ] Caching strategy
|
|
||||||
- [ ] Security hardening
|
|
||||||
- [ ] Deployment pipeline
|
|
||||||
- [ ] CI/CD setup
|
|
||||||
|
|
||||||
## AAA System (Authentication, Authorization, Accounting) - Next.js Implementation
|
#### Next.js 16 Breaking Change Fixes
|
||||||
|
- [x] Fixed `params.id` usage in all page components (must use `await params`)
|
||||||
|
- [x] Fixed `params.id` usage in all API routes (must use `await params`)
|
||||||
|
- [x] Updated client components to use `Promise<{ id: string }>` type
|
||||||
|
- [x] Added regression tests for Next.js 16 params Promise handling
|
||||||
|
- [x] Verified all 100 unit tests pass
|
||||||
|
|
||||||
### Authentication (Better Auth + Prisma)
|
#### Files Updated:
|
||||||
- [x] Set up Better Auth with Prisma
|
- Player pages: `profile.tsx`, `schedule.tsx`
|
||||||
- [x] Create users table schema
|
- Tournament pages: `page.tsx`, `results.tsx`, `edit.tsx`, `entry.tsx`
|
||||||
- [x] Build login page (`/auth/login`)
|
- API routes: `admin/players/[id]/route.ts`, `users/[id]/route.ts`, `users/[id]/role/route.ts`
|
||||||
- [x] Build registration page (`/auth/register`)
|
- Tournament API routes: `[id]/route.ts`, `[id]/participants/route.ts`, `[id]/games/bulk/route.ts`
|
||||||
- [x] Implement session management with Better Auth
|
|
||||||
- [x] Add authentication middleware
|
|
||||||
- [ ] Password reset functionality
|
|
||||||
- [ ] Email confirmation system
|
|
||||||
- [ ] OAuth providers (optional)
|
|
||||||
|
|
||||||
### Authorization (RBAC)
|
#### Root Cause
|
||||||
- [x] Define roles in Prisma schema (PLAYER, TOURNAMENT_ADMIN, CLUB_ADMIN)
|
Next.js 16 requires `params` to be awaited in both server components and API routes:
|
||||||
- [x] Implement authorization helpers
|
- Before: `const { id } = params`
|
||||||
- [x] Add authorization to admin dashboard
|
- After: `const { id } = await params`
|
||||||
- [x] Add authorization to player management
|
|
||||||
- [x] Add authorization to tournament management
|
|
||||||
- [x] Add 5-minute match edit window (player role)
|
|
||||||
- [ ] Add role assignment UI for club admins
|
|
||||||
- [ ] Add permission checks to all API routes
|
|
||||||
|
|
||||||
### Accounting (Activity Logging)
|
This was not caught by the unit test suite because:
|
||||||
- [ ] Create activity logging system (Prisma model)
|
- Unit tests test individual functions in isolation
|
||||||
- [ ] Track authentication events
|
- E2E tests (Playwright) would catch this but weren't run after the upgrade
|
||||||
- [ ] Track tournament management events
|
|
||||||
- [ ] Track match recording events
|
|
||||||
- [ ] Build audit reports UI
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- [x] Rate limiting (Better Auth built-in)
|
|
||||||
- [ ] IP-based lockout
|
|
||||||
- [x] Secure cookie settings (Better Auth)
|
|
||||||
- [x] CSRF protection (Next.js built-in)
|
|
||||||
- [ ] Security headers
|
|
||||||
- [ ] Session fixation prevention
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
- [ ] Database IDs not resetting between tests (workaround: query by round_number)
|
|
||||||
- [ ] Need to clean up debug output from acceptance tests
|
|
||||||
- [ ] Password reset flow not yet implemented
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
1. Design UI mockups for each view type
|
|
||||||
2. Implement navigation system
|
|
||||||
3. Build out Tournament Admin view
|
|
||||||
4. Add role-based access control
|
|
||||||
5. Create reusable UI components
|
|
||||||
|
|||||||
+3
-3
@@ -79,7 +79,7 @@ test.describe('CSV Upload Player Deduplication', () => {
|
|||||||
formData.append('eventId', testTournamentId.toString());
|
formData.append('eventId', testTournamentId.toString());
|
||||||
|
|
||||||
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
||||||
data: formData,
|
multipart: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
@@ -133,7 +133,7 @@ test.describe('CSV Upload Player Deduplication', () => {
|
|||||||
formData.append('eventId', testTournamentId.toString());
|
formData.append('eventId', testTournamentId.toString());
|
||||||
|
|
||||||
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
||||||
data: formData,
|
multipart: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
@@ -190,7 +190,7 @@ test.describe('CSV Upload Player Deduplication', () => {
|
|||||||
formData.append('eventId', testTournamentId.toString());
|
formData.append('eventId', testTournamentId.toString());
|
||||||
|
|
||||||
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
||||||
data: formData,
|
multipart: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Gherkin-Style E2E Tests - Feature Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This directory contains Gherkin-style acceptance tests for the EuchreCamp application, written in Cucumber syntax and executed via Playwright.
|
||||||
|
|
||||||
|
## Coverage Summary
|
||||||
|
|
||||||
|
### Feature Files (6 files, 16 scenarios)
|
||||||
|
|
||||||
|
| Feature File | Issue | Scenarios | Status |
|
||||||
|
|--------------|-------|-----------|--------|
|
||||||
|
| authentication.feature | - | 5 | ✅ Complete |
|
||||||
|
| password-reset.feature | #10 | 4 | ⚠️ @wip (needs UI) |
|
||||||
|
| player-schedule.feature | #9 | 3 | ⚠️ @wip (needs data) |
|
||||||
|
| tournament-schedule.feature | #7 | 4 | ⚠️ @wip (needs data) |
|
||||||
|
| user-registration.feature | - | 3 | ✅ Complete |
|
||||||
|
| wordmark-navigation.feature | #24 | 3 | ✅ Complete |
|
||||||
|
|
||||||
|
### Step Definitions
|
||||||
|
|
||||||
|
- **Total steps defined:** 75
|
||||||
|
- **Common steps:** Navigation, forms, assertions
|
||||||
|
- **Auth steps:** Registration, login, logout, schedule navigation
|
||||||
|
- **All steps verified:** ✅ Dry-run passes with no undefined steps
|
||||||
|
|
||||||
|
## Issues Covered
|
||||||
|
|
||||||
|
### Issue #24: Wordmark Navigation ✅
|
||||||
|
**Scenario:** Clicking EuchreCamp wordmark navigates to appropriate page
|
||||||
|
- Unauthenticated users → Public homepage
|
||||||
|
- Players → Their profile page
|
||||||
|
- Admins → Admin dashboard
|
||||||
|
|
||||||
|
### Issue #10: Password Reset ⚠️
|
||||||
|
**Scenarios:**
|
||||||
|
- Access password reset page
|
||||||
|
- Request reset with valid email
|
||||||
|
- Request reset with invalid email
|
||||||
|
- Submit empty email field
|
||||||
|
|
||||||
|
**Status:** Marked @wip because password reset UI is not yet implemented
|
||||||
|
|
||||||
|
### Issue #9: Player Schedule ⚠️
|
||||||
|
**Scenarios:**
|
||||||
|
- View empty schedule (no matches)
|
||||||
|
- View schedule with upcoming matches
|
||||||
|
- Navigate to match details
|
||||||
|
|
||||||
|
**Status:** Marked @wip because data setup requires tournament creation
|
||||||
|
|
||||||
|
### Issue #7: Tournament Schedule Tab ⚠️
|
||||||
|
**Scenarios:**
|
||||||
|
- View schedule page for tournament
|
||||||
|
- Generate round-robin schedule
|
||||||
|
- View schedule with bye rounds
|
||||||
|
- Click matchup to enter results
|
||||||
|
|
||||||
|
**Status:** Marked @wip because data setup requires tournament creation
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Command Line
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all Cucumber tests
|
||||||
|
bun run test:acceptance:cucumber
|
||||||
|
|
||||||
|
# Run with pretty output
|
||||||
|
bun run test:acceptance:cucumber:pretty
|
||||||
|
|
||||||
|
# Run specific feature
|
||||||
|
bun cucumber-js e2e/cucumber/features/user-registration.feature
|
||||||
|
|
||||||
|
# Dry run (verify all steps defined)
|
||||||
|
bun cucumber-js --config e2e/cucumber/cucumber.config.ts --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Filter tests by tag:
|
||||||
|
```bash
|
||||||
|
# Run only @happy-path tests
|
||||||
|
bun cucumber-js --tags "@happy-path"
|
||||||
|
|
||||||
|
# Skip @wip tests
|
||||||
|
bun cucumber-js --tags "not @wip"
|
||||||
|
|
||||||
|
# Run specific issue tests
|
||||||
|
bun cucumber-js --tags "@issue-24"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Examples
|
||||||
|
|
||||||
|
### Wordmark Navigation (Issue #24)
|
||||||
|
```gherkin
|
||||||
|
Scenario: Authenticated player clicks wordmark goes to their profile
|
||||||
|
Given I am logged in as a player
|
||||||
|
When I click the "EuchreCamp" wordmark
|
||||||
|
Then I should be redirected to my profile page
|
||||||
|
And I should see "Welcome"
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
```gherkin
|
||||||
|
Scenario: Successful registration with valid data
|
||||||
|
Given I am on the registration page
|
||||||
|
When I fill in "name" with "Test User"
|
||||||
|
And I fill in "email" with "test@example.com"
|
||||||
|
And I fill in "password" with "TestPassword123!"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should be redirected to my profile page
|
||||||
|
And my user account should exist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tournament Schedule (Issue #7)
|
||||||
|
```gherkin
|
||||||
|
Scenario: Tournament admin generates round-robin schedule
|
||||||
|
Given I am logged in as a tournament admin
|
||||||
|
And a tournament exists with 4 teams
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
And I click the "Generate Schedule" button
|
||||||
|
Then I should see "Schedule generated successfully"
|
||||||
|
And I should see round 1 matchups
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
1. **Browser-only interactions** - Tests interact with UI only, no database access
|
||||||
|
2. **Gherkin syntax** - Plain English Given-When-Then format
|
||||||
|
3. **Happy path focus** - Tests common user workflows
|
||||||
|
4. **Issue tracking** - Each feature file linked to specific GitHub issues
|
||||||
|
5. **@wip markers** - New/incomplete features marked for development
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Immediate
|
||||||
|
1. Run tests against dev server: `bun run test:acceptance:cucumber`
|
||||||
|
2. Review @wip scenarios and implement missing UI
|
||||||
|
3. Set up test data creation via API or fixtures
|
||||||
|
|
||||||
|
### Short-term
|
||||||
|
1. Add more feature files for other issues:
|
||||||
|
- Issue #8: Tournament bracket visualization
|
||||||
|
- Issue #11: Club admin dashboard
|
||||||
|
- Issue #15: Admin view-as-user functionality
|
||||||
|
- Issue #22: Team configuration options
|
||||||
|
|
||||||
|
2. Add more step definitions for:
|
||||||
|
- Tournament creation
|
||||||
|
- Team management
|
||||||
|
- Match result entry
|
||||||
|
- Calendar view
|
||||||
|
|
||||||
|
### Long-term
|
||||||
|
1. Integrate with CI/CD pipeline
|
||||||
|
2. Generate HTML reports
|
||||||
|
3. Add visual regression testing
|
||||||
|
4. Create test data factory for complex scenarios
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all feature files
|
||||||
|
ls e2e/cucumber/features/
|
||||||
|
|
||||||
|
# Count scenarios
|
||||||
|
grep -c "Scenario:" e2e/cucumber/features/*.feature
|
||||||
|
|
||||||
|
# List all step definitions
|
||||||
|
grep -r "Given\|When\|Then" e2e/cucumber/step-definitions/ | wc -l
|
||||||
|
|
||||||
|
# Check for @wip scenarios
|
||||||
|
grep -n "@wip" e2e/cucumber/features/*.feature
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Cucumber.js Documentation](https://github.com/cucumber/cucumber-js)
|
||||||
|
- [Gherkin Reference](https://cucumber.io/docs/gherkin/)
|
||||||
|
- [Playwright Documentation](https://playwright.dev)
|
||||||
|
- [Feature README](./README.md)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Cucumber Gherkin-Style E2E Tests
|
||||||
|
|
||||||
|
This directory contains Gherkin-style acceptance tests using Cucumber and Playwright for testing the EuchreCamp application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
These tests follow the **Given-When-Then** syntax for behavior-driven development (BDD) and test the application from a user's perspective by interacting with the browser UI only.
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
1. **Browser-only interactions**: Tests interact with the application via the browser (click, type, navigate)
|
||||||
|
2. **No direct database access**: All data is created/modified through the UI
|
||||||
|
3. **Dev site testing**: Tests run against a running development server
|
||||||
|
4. **Happy path focus**: Tests verify common user workflows
|
||||||
|
5. **Gherkin syntax**: Tests are written in plain English using Given-When-Then
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run all Cucumber tests
|
||||||
|
```bash
|
||||||
|
bun run test:acceptance:cucumber
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with pretty formatter (visible output)
|
||||||
|
```bash
|
||||||
|
bun run test:acceptance:cucumber:pretty
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run specific feature file
|
||||||
|
```bash
|
||||||
|
bun cucumber-js e2e/cucumber/features/user-registration.feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run specific scenario
|
||||||
|
```bash
|
||||||
|
bun cucumber-js --name "Successful registration with valid data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dry run (check for undefined steps)
|
||||||
|
```bash
|
||||||
|
bun cucumber-js --config e2e/cucumber/cucumber.config.ts --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/cucumber/
|
||||||
|
├── features/ # Gherkin feature files (6 files, 16 scenarios)
|
||||||
|
│ ├── authentication.feature # Login/logout flows
|
||||||
|
│ ├── password-reset.feature # Password reset (issue #10)
|
||||||
|
│ ├── player-schedule.feature # Player schedule view (issue #9)
|
||||||
|
│ ├── tournament-schedule.feature # Tournament schedule tab (issue #7)
|
||||||
|
│ ├── user-registration.feature # User registration flows
|
||||||
|
│ └── wordmark-navigation.feature # Wordmark navigation (issue #24)
|
||||||
|
├── step-definitions/ # Step implementations (75 steps defined)
|
||||||
|
│ ├── common-steps.ts # Navigation, forms, assertions
|
||||||
|
│ └── auth-steps.ts # Login, logout, registration, schedule
|
||||||
|
├── support/ # Test infrastructure
|
||||||
|
│ ├── world.ts # Shared test context
|
||||||
|
│ └── hooks.ts # Before/After hooks
|
||||||
|
├── cucumber.config.ts # Cucumber configuration
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Files
|
||||||
|
|
||||||
|
### 1. user-registration.feature
|
||||||
|
User registration scenarios with validation
|
||||||
|
|
||||||
|
### 2. authentication.feature
|
||||||
|
Login/logout flows for authenticated users
|
||||||
|
|
||||||
|
### 3. wordmark-navigation.feature
|
||||||
|
Tests for Issue #24: Wordmark navigation based on user role
|
||||||
|
|
||||||
|
### 4. password-reset.feature
|
||||||
|
Tests for Issue #10: Password reset flow (marked @wip - needs implementation)
|
||||||
|
|
||||||
|
### 5. player-schedule.feature
|
||||||
|
Tests for Issue #9: Player schedule view with upcoming matches
|
||||||
|
|
||||||
|
### 6. tournament-schedule.feature
|
||||||
|
Tests for Issue #7: Tournament admin schedule tab and round-robin generation
|
||||||
|
|
||||||
|
## Example Test
|
||||||
|
|
||||||
|
### Feature File (user-registration.feature)
|
||||||
|
```gherkin
|
||||||
|
Feature: User Registration
|
||||||
|
As a new user
|
||||||
|
I want to register for an account
|
||||||
|
So that I can participate in Euchre tournaments
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am on the registration page
|
||||||
|
|
||||||
|
Scenario: Successful registration with valid data
|
||||||
|
When I fill in "name" with "Test User"
|
||||||
|
And I fill in "email" with "test@example.com"
|
||||||
|
And I fill in "password" with "TestPassword123!"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should be redirected to my profile page
|
||||||
|
And my user account should exist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step Definition (auth-steps.ts)
|
||||||
|
```typescript
|
||||||
|
Given('I am logged in as a player', async function () {
|
||||||
|
const credentials = generateTestCredentials();
|
||||||
|
world.user = credentials;
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
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);
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
await world.page.waitForURL(/\/players\/\d+\/profile/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Use tags to organize and filter tests:
|
||||||
|
|
||||||
|
- `@happy-path` - Tests successful user workflows
|
||||||
|
- `@negative-test` - Tests error cases and validation
|
||||||
|
- `@authentication` - Tests related to login/registration
|
||||||
|
- `@wip` - Work in progress (skipped by default)
|
||||||
|
- `@skip` - Temporarily skipped tests
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The Cucumber configuration is in `cucumber.config.ts`:
|
||||||
|
|
||||||
|
- Feature files: `e2e/cucumber/features/**/*.feature`
|
||||||
|
- Step definitions: `e2e/cucumber/step-definitions/**/*.ts`
|
||||||
|
- TypeScript loader: tsx (for path alias support)
|
||||||
|
- Tags: Skips `@wip` and `@skip` by default
|
||||||
|
- Retries: 2 retries in CI environment
|
||||||
|
|
||||||
|
## World Context
|
||||||
|
|
||||||
|
The `world.ts` file provides a shared context for tests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WorldState {
|
||||||
|
page: Page; // Playwright page
|
||||||
|
context: BrowserContext;
|
||||||
|
baseURL: string; // Dev server URL
|
||||||
|
user?: { // Current test user
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
tournament?: any; // Current test tournament
|
||||||
|
player?: any; // Current test player
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
Hooks run before/after tests:
|
||||||
|
|
||||||
|
- `BeforeAll` - Launch browser once before all tests
|
||||||
|
- `AfterAll` - Close browser and cleanup after all tests
|
||||||
|
- `Before` - Create new page context before each scenario
|
||||||
|
- `After` - Close page and cleanup test data after each scenario
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Unique test data**: Use timestamps to ensure unique test users
|
||||||
|
2. **Wait for navigation**: Always wait for page loads and redirects
|
||||||
|
3. **Clear assertions**: Verify expected state after user actions
|
||||||
|
4. **No database access**: Use UI/API for all data creation
|
||||||
|
5. **Tag organization**: Use tags to filter test suites
|
||||||
|
6. **One assertion per step**: Keep steps focused and readable
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Steps showing as undefined
|
||||||
|
Run with `--dry-run` to check step matching:
|
||||||
|
```bash
|
||||||
|
bun cucumber-js --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database errors
|
||||||
|
These tests don't interact with the database directly. If you see Prisma errors, check that:
|
||||||
|
- The dev server is running
|
||||||
|
- The database is accessible
|
||||||
|
- Environment variables are set correctly
|
||||||
|
|
||||||
|
### Slow tests
|
||||||
|
Use the `@wip` tag for tests in development and skip them during full runs.
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
In CI, the tests should:
|
||||||
|
1. Start the dev server (if not already running)
|
||||||
|
2. Run Cucumber tests against `http://localhost:3000`
|
||||||
|
3. Use JUnit format for reporting:
|
||||||
|
```bash
|
||||||
|
bun cucumber-js --format junit --out reports/cucumber.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Cucumber.js Documentation](https://github.com/cucumber/cucumber-js)
|
||||||
|
- [Gherkin Syntax Reference](https://cucumber.io/docs/gherkin/)
|
||||||
|
- [Playwright Documentation](https://playwright.dev)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Cucumber configuration for EuchreCamp E2E tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Paths to feature files
|
||||||
|
paths: ['e2e/cucumber/features/**/*.feature'],
|
||||||
|
|
||||||
|
// Paths to step definitions
|
||||||
|
import: ['e2e/cucumber/step-definitions/**/*.ts'],
|
||||||
|
|
||||||
|
// Paths to support files (hooks, world)
|
||||||
|
require: ['e2e/cucumber/support/**/*.ts'],
|
||||||
|
|
||||||
|
// Use tsx loader for TypeScript with path aliases
|
||||||
|
requireModule: ['tsx'],
|
||||||
|
|
||||||
|
// Format options
|
||||||
|
format: [
|
||||||
|
'progress-bar',
|
||||||
|
'pretty:cucumber-pretty'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Output directory for reports
|
||||||
|
formatOptions: {
|
||||||
|
snippetInterface: 'async-await'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tags to run (can be overridden via command line)
|
||||||
|
tags: 'not @wip and not @skip',
|
||||||
|
|
||||||
|
// Fail fast on first error
|
||||||
|
failFast: false,
|
||||||
|
|
||||||
|
// Retry failed tests (useful in CI)
|
||||||
|
retry: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
|
// Dry run (just list scenarios without executing)
|
||||||
|
dryRun: false,
|
||||||
|
|
||||||
|
// Strict mode (fail on undefined steps)
|
||||||
|
strict: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
Feature: User Authentication
|
||||||
|
As a registered user
|
||||||
|
I want to log in and log out
|
||||||
|
So that I can access my account features
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Login with valid credentials
|
||||||
|
Given I am logged in as a player
|
||||||
|
When I go to the home page
|
||||||
|
Then I should see "Sign out"
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Logout successfully
|
||||||
|
Given I am logged in as a player
|
||||||
|
When I click the "Sign out" button
|
||||||
|
Then I should be redirected to the login page
|
||||||
|
|
||||||
|
@negative-test @authentication
|
||||||
|
Scenario: Login with invalid credentials
|
||||||
|
Given I am on the login page
|
||||||
|
When I log in with invalid credentials
|
||||||
|
Then I should see an error message
|
||||||
|
And I should remain on the login page
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Navigate to login page
|
||||||
|
Given I am on the home page
|
||||||
|
When I click the "Log in" link
|
||||||
|
Then I should be on the login page
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Navigate to registration from login
|
||||||
|
Given I am on the login page
|
||||||
|
When I click the "Create an account" link
|
||||||
|
Then I should be on the registration page
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
Feature: Password Reset
|
||||||
|
As a user who forgot my password
|
||||||
|
I want to reset my password
|
||||||
|
So that I can regain access to my account
|
||||||
|
|
||||||
|
@happy-path @authentication @issue-10
|
||||||
|
Scenario: User can access password reset page
|
||||||
|
Given I am on the login page
|
||||||
|
When I click the "Forgot password?" link
|
||||||
|
Then I should be on the password reset page
|
||||||
|
And I should see "Reset Password"
|
||||||
|
|
||||||
|
@happy-path @authentication @issue-10 @wip
|
||||||
|
Scenario: User requests password reset with valid email
|
||||||
|
Given I am on the password reset page
|
||||||
|
When I fill in "email" with "existing-user@example.com"
|
||||||
|
And I click the "Send Reset Link" button
|
||||||
|
Then I should see "Password reset link sent"
|
||||||
|
And I should see "Check your email"
|
||||||
|
|
||||||
|
@negative-test @authentication @issue-10 @wip
|
||||||
|
Scenario: User requests password reset with invalid email
|
||||||
|
Given I am on the password reset page
|
||||||
|
When I fill in "email" with "nonexistent@example.com"
|
||||||
|
And I click the "Send Reset Link" button
|
||||||
|
Then I should see an error message
|
||||||
|
|
||||||
|
@negative-test @authentication @issue-10 @wip
|
||||||
|
Scenario: User submits empty email field
|
||||||
|
Given I am on the password reset page
|
||||||
|
When I click the "Send Reset Link" button
|
||||||
|
Then I should see "email is required" error
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Feature: Player Schedule
|
||||||
|
As a player
|
||||||
|
I want to see my upcoming matches
|
||||||
|
So that I can prepare for and attend my games
|
||||||
|
|
||||||
|
@happy-path @player-features @issue-9
|
||||||
|
Scenario: Player views empty schedule when no matches
|
||||||
|
Given I am logged in as a player
|
||||||
|
When I go to my schedule page
|
||||||
|
Then I should see "No upcoming matches"
|
||||||
|
|
||||||
|
@happy-path @player-features @issue-9 @wip
|
||||||
|
Scenario: Player views schedule with upcoming matches
|
||||||
|
Given I am logged in as a player
|
||||||
|
And I have upcoming matches in my schedule
|
||||||
|
When I go to my schedule page
|
||||||
|
Then I should see "Upcoming Matches"
|
||||||
|
And I should see the match date
|
||||||
|
And I should see my opponent's name
|
||||||
|
And I should see the tournament name
|
||||||
|
|
||||||
|
@happy-path @player-features @issue-9 @wip
|
||||||
|
Scenario: Player can navigate to match details from schedule
|
||||||
|
Given I am logged in as a player
|
||||||
|
And I have upcoming matches in my schedule
|
||||||
|
When I go to my schedule page
|
||||||
|
And I click on a match
|
||||||
|
Then I should be on the match detail page
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
Feature: Tournament Schedule
|
||||||
|
As a tournament admin
|
||||||
|
I want to view and generate round matchups in a Schedule tab
|
||||||
|
So that I can manage tournament matches
|
||||||
|
|
||||||
|
@happy-path @tournament @issue-7
|
||||||
|
Scenario: Tournament admin views schedule page for tournament
|
||||||
|
Given I am logged in as a tournament admin
|
||||||
|
And a tournament exists with 4 teams
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
Then I should see "Schedule"
|
||||||
|
And I should see the "Generate Schedule" button
|
||||||
|
|
||||||
|
@happy-path @tournament @issue-7 @wip
|
||||||
|
Scenario: Tournament admin generates round-robin schedule
|
||||||
|
Given I am logged in as a tournament admin
|
||||||
|
And a tournament exists with 4 teams
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
And I click the "Generate Schedule" button
|
||||||
|
Then I should see "Schedule generated successfully"
|
||||||
|
And I should see round 1 matchups
|
||||||
|
And I should see round 2 matchups
|
||||||
|
|
||||||
|
@happy-path @tournament @issue-7 @wip
|
||||||
|
Scenario: Tournament admin views schedule with bye rounds
|
||||||
|
Given I am logged in as a tournament admin
|
||||||
|
And a tournament exists with 5 teams
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
And I click the "Generate Schedule" button
|
||||||
|
Then I should see a bye round for one team
|
||||||
|
And each team should play every other team exactly once
|
||||||
|
|
||||||
|
@happy-path @tournament @issue-7 @wip
|
||||||
|
Scenario: Tournament admin clicks on a matchup to enter results
|
||||||
|
Given I am logged in as a tournament admin
|
||||||
|
And a tournament has a generated schedule
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
And I click on a matchup
|
||||||
|
Then I should be on the match result entry page
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
Feature: User Registration
|
||||||
|
As a new user
|
||||||
|
I want to register for an account
|
||||||
|
So that I can participate in Euchre tournaments and track my games
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am on the registration page
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Successful registration with valid data
|
||||||
|
When I fill in "name" with "Test User"
|
||||||
|
And I fill in "email" with "test-user@example.com"
|
||||||
|
And I fill in "password" with "TestPassword123!"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should be redirected to my profile page
|
||||||
|
And my user account should exist
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Auto-created player profile is linked to user
|
||||||
|
When I fill in "name" with "Profile Test User"
|
||||||
|
And I fill in "email" with "profile-test@example.com"
|
||||||
|
And I fill in "password" with "ProfilePass123!"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should be redirected to my profile page
|
||||||
|
And I should see "Welcome, Profile Test User"
|
||||||
|
|
||||||
|
@negative-test @authentication
|
||||||
|
Scenario: Registration with duplicate email fails
|
||||||
|
Given I am logged in as a player
|
||||||
|
When I navigate to the registration page
|
||||||
|
And I fill in "name" with "Duplicate User"
|
||||||
|
And I fill in "email" with the same email
|
||||||
|
And I fill in "password" with "TestPassword123!"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should see a registration error
|
||||||
|
|
||||||
|
@negative-test @authentication
|
||||||
|
Scenario: Registration with weak password fails
|
||||||
|
When I fill in "name" with "Weak Password User"
|
||||||
|
And I fill in "email" with "weak@example.com"
|
||||||
|
And I fill in "password" with "weak"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should see "password" validation error
|
||||||
|
And I should remain on the registration page
|
||||||
|
|
||||||
|
@happy-path @authentication
|
||||||
|
Scenario: Registration form validation
|
||||||
|
When I click the "Create Account" button
|
||||||
|
Then I should see "name is required" error
|
||||||
|
And I should see "email is required" error
|
||||||
|
And I should see "password is required" error
|
||||||
|
|
||||||
|
@wip
|
||||||
|
Scenario: Registration with email verification (placeholder)
|
||||||
|
When I fill in "name" with "Email Verify User"
|
||||||
|
And I fill in "email" with "verify@example.com"
|
||||||
|
And I fill in "password" with "TestPassword123!"
|
||||||
|
And I click the "Create Account" button
|
||||||
|
Then I should see "Please check your email to verify your account"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
Feature: Wordmark Navigation
|
||||||
|
As a user
|
||||||
|
I want the EuchreCamp wordmark to navigate to my appropriate homepage
|
||||||
|
So that I can quickly access my relevant content
|
||||||
|
|
||||||
|
@happy-path @navigation @issue-24
|
||||||
|
Scenario: Unauthenticated user clicks wordmark goes to public homepage
|
||||||
|
Given I am on the home page
|
||||||
|
When I click the "EuchreCamp" wordmark
|
||||||
|
Then I should be on the home page
|
||||||
|
And I should see "Top 10 Players"
|
||||||
|
And I should see "Sign In"
|
||||||
|
|
||||||
|
@happy-path @navigation @authenticated @issue-24
|
||||||
|
Scenario: Authenticated player clicks wordmark goes to their profile
|
||||||
|
Given I am logged in as a player
|
||||||
|
When I click the "EuchreCamp" wordmark
|
||||||
|
Then I should be redirected to my profile page
|
||||||
|
And I should see "Welcome"
|
||||||
|
|
||||||
|
@happy-path @navigation @authenticated @issue-24
|
||||||
|
Scenario: Authenticated admin clicks wordmark goes to admin dashboard
|
||||||
|
Given I am logged in as a club admin
|
||||||
|
When I click the "EuchreCamp" wordmark
|
||||||
|
Then I should be on the admin page
|
||||||
|
And I should see "Admin"
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* Authentication step definitions for EuchreCamp E2E tests
|
||||||
|
*
|
||||||
|
* These steps interact ONLY with the browser UI - no direct database access.
|
||||||
|
* All user creation happens via the registration/login UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Given, When, Then } from '@cucumber/cucumber';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { world } from '../support/world';
|
||||||
|
|
||||||
|
// Generate unique test credentials
|
||||||
|
function generateTestCredentials() {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
return {
|
||||||
|
email: `cucumber-test-${timestamp}@example.com`,
|
||||||
|
password: 'TestPassword123!',
|
||||||
|
name: `Cucumber Test User ${timestamp}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precondition: I am logged in as a player
|
||||||
|
* Creates a new user via the registration UI
|
||||||
|
*/
|
||||||
|
Given('I am logged in as a player', async function () {
|
||||||
|
console.log('🌍 Creating and logging in as a player via UI...');
|
||||||
|
|
||||||
|
const credentials = generateTestCredentials();
|
||||||
|
world.user = credentials;
|
||||||
|
|
||||||
|
// Navigate to registration page
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Fill registration form
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for redirect to profile page
|
||||||
|
try {
|
||||||
|
await world.page.waitForURL(/\/players\/\d+\/profile/, { timeout: 10000 });
|
||||||
|
console.log(`🌍 Player created and logged in: ${credentials.email}`);
|
||||||
|
} catch (e) {
|
||||||
|
// If redirect doesn't happen, check if we're still on the page
|
||||||
|
console.log('🌍 Registration may have failed or redirected elsewhere');
|
||||||
|
console.log('🌍 Current URL:', world.page.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precondition: I am logged in as a tournament admin
|
||||||
|
* Note: In the actual app, admin roles are assigned by club admins or via API.
|
||||||
|
* For acceptance tests, we'll use the default player role and test admin features
|
||||||
|
* as the dev site would handle them.
|
||||||
|
*/
|
||||||
|
Given('I am logged in as a tournament admin', async function () {
|
||||||
|
console.log('🌍 Creating and logging in as a player (tournament admin role is assigned via UI/API)...');
|
||||||
|
// For now, use the same flow as player
|
||||||
|
// In real usage, the admin would either:
|
||||||
|
// 1. Be pre-created on the dev site
|
||||||
|
// 2. Have role assigned via API
|
||||||
|
// 3. Use the admin dashboard to manage users
|
||||||
|
|
||||||
|
const credentials = generateTestCredentials();
|
||||||
|
world.user = credentials;
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
console.log(`🌍 User created: ${credentials.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precondition: I am logged in as a club admin
|
||||||
|
*/
|
||||||
|
Given('I am logged in as a club admin', async function () {
|
||||||
|
console.log('🌍 Creating and logging in as a club admin...');
|
||||||
|
|
||||||
|
const credentials = generateTestCredentials();
|
||||||
|
world.user = credentials;
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
console.log(`🌍 User created: ${credentials.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration steps (UI-only)
|
||||||
|
*/
|
||||||
|
When('I register with valid credentials', async function () {
|
||||||
|
const credentials = generateTestCredentials();
|
||||||
|
world.user = credentials;
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
console.log(`🌍 Registered with credentials: ${credentials.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I register with duplicate email', async function () {
|
||||||
|
if (!world.user) {
|
||||||
|
throw new Error('No existing user to duplicate');
|
||||||
|
}
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await world.page.fill('input[name="name"]', world.user.name);
|
||||||
|
await world.page.fill('input[name="email"]', world.user.email);
|
||||||
|
await world.page.fill('input[name="password"]', world.user.password);
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
console.log(`🌍 Attempted registration with duplicate: ${world.user.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I fill in {string} with the same email', async function (fieldName: string) {
|
||||||
|
if (!world.user) {
|
||||||
|
throw new Error('No user credentials available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = `input[name="email"]`;
|
||||||
|
console.log(`🌍 Filling ${fieldName} with same email: ${world.user.email}`);
|
||||||
|
await world.page.fill(selector, world.user.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I register with weak password', async function () {
|
||||||
|
const credentials = generateTestCredentials();
|
||||||
|
credentials.password = 'weak'; // Too short
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
console.log('🌍 Attempted registration with weak password');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login steps (UI-only)
|
||||||
|
*/
|
||||||
|
When('I log in with valid credentials', async function () {
|
||||||
|
if (!world.user) {
|
||||||
|
throw new Error('No user credentials available');
|
||||||
|
}
|
||||||
|
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/login`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await world.page.fill('input[name="email"]', world.user.email);
|
||||||
|
await world.page.fill('input[name="password"]', world.user.password);
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
console.log(`🌍 Logged in as: ${world.user.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I log in with invalid credentials', async function () {
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/login`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await world.page.fill('input[name="email"]', 'nonexistent@example.com');
|
||||||
|
await world.page.fill('input[name="password"]', 'wrongpassword');
|
||||||
|
|
||||||
|
await world.page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
console.log('🌍 Attempted login with invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout steps (UI-only)
|
||||||
|
*/
|
||||||
|
When('I log out', async function () {
|
||||||
|
// Click on logout button
|
||||||
|
await world.page.click('text=Sign out');
|
||||||
|
|
||||||
|
console.log('🌍 Logged out');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification steps (browser-based)
|
||||||
|
*/
|
||||||
|
Then('I should be logged in', async function () {
|
||||||
|
// Check for logout button or user menu
|
||||||
|
await expect(world.page.locator('text=Sign out')).toBeVisible();
|
||||||
|
console.log('🌍 Verified user is logged in');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should not be logged in', async function () {
|
||||||
|
// Check that we're on login page or don't see logout button
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
expect(currentUrl).toContain('/auth/login');
|
||||||
|
console.log('🌍 Verified user is not logged in');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see a registration error', async function () {
|
||||||
|
// Check for error message in page content
|
||||||
|
const content = await world.page.content();
|
||||||
|
expect(content).toContain('error');
|
||||||
|
console.log('🌍 Verified registration error is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see an error message', async function () {
|
||||||
|
// Check for any error message on the page
|
||||||
|
const content = await world.page.content();
|
||||||
|
expect(content).toMatch(/error|Error/i);
|
||||||
|
console.log('🌍 Verified error message is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should remain on the login page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
expect(currentUrl).toContain('/auth/login');
|
||||||
|
console.log('🌍 Verified still on login page');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should remain on the registration page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
expect(currentUrl).toContain('/auth/register');
|
||||||
|
console.log('🌍 Verified still on registration page');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Verify user was created via UI (no database check)
|
||||||
|
*/
|
||||||
|
Then('my user account should exist', async function () {
|
||||||
|
if (!world.user) {
|
||||||
|
throw new Error('No user credentials available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't check the database directly in acceptance tests
|
||||||
|
// Instead, verify we're logged in (user profile page)
|
||||||
|
await expect(world.page.locator('text=Welcome')).toBeVisible();
|
||||||
|
console.log('🌍 Verified user account was created successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule steps (player)
|
||||||
|
*/
|
||||||
|
When('I go to my schedule page', async function () {
|
||||||
|
console.log('🌍 Going to schedule page');
|
||||||
|
// Navigate to the schedule page
|
||||||
|
// The URL pattern would be /players/{playerId}/schedule
|
||||||
|
// We'll navigate to /players/schedule which should redirect or work
|
||||||
|
await world.page.goto(`${world.baseURL}/players/schedule`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have upcoming matches in my schedule', async function () {
|
||||||
|
console.log('🌍 Note: This step requires database setup via API or UI');
|
||||||
|
console.log('🌍 For acceptance tests, this would be set up before running the test');
|
||||||
|
// For true acceptance testing, we would:
|
||||||
|
// 1. Create a tournament
|
||||||
|
// 2. Add the player as a participant
|
||||||
|
// 3. Generate a schedule
|
||||||
|
// 4. The match would then appear in the player's schedule
|
||||||
|
|
||||||
|
// For now, this is a placeholder that indicates data setup is needed
|
||||||
|
// In a real test run, this data would already exist in the dev database
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tournament schedule steps
|
||||||
|
*/
|
||||||
|
Given('a tournament exists with {int} teams', async function (teamCount: number) {
|
||||||
|
console.log(`🌍 Note: Tournament with ${teamCount} teams requires data setup`);
|
||||||
|
console.log('🌍 For acceptance tests, this would be created via UI or API');
|
||||||
|
// In a real test run, this would either:
|
||||||
|
// 1. Create a tournament via the UI (slower but more realistic)
|
||||||
|
// 2. Use API to create tournament and add teams (faster)
|
||||||
|
// 3. Pre-existing test data in dev database
|
||||||
|
|
||||||
|
// Store the team count in world context for later steps
|
||||||
|
world.tournamentTeamCount = teamCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go to the tournament schedule page', async function () {
|
||||||
|
console.log('🌍 Going to tournament schedule page');
|
||||||
|
// Navigate to a tournament schedule page
|
||||||
|
// This would typically be /admin/tournaments/{id}/schedule
|
||||||
|
// For testing, we'll go to the first tournament's schedule
|
||||||
|
await world.page.goto(`${world.baseURL}/admin/tournaments/1/schedule`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('a tournament has a generated schedule', async function () {
|
||||||
|
console.log('🌍 Note: Tournament schedule requires generation via API or UI');
|
||||||
|
console.log('🌍 For acceptance tests, this would be created before running the test');
|
||||||
|
// In a real test run, we would:
|
||||||
|
// 1. Create a tournament
|
||||||
|
// 2. Add teams/participants
|
||||||
|
// 3. Generate schedule via API or UI
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* Common step definitions for EuchreCamp E2E tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Given, When, Then } from '@cucumber/cucumber';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { world } from '../support/world';
|
||||||
|
|
||||||
|
// console.log('🌍 Loading common-steps.ts step definitions');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation steps
|
||||||
|
*/
|
||||||
|
Given('I am on the home page', async function () {
|
||||||
|
console.log('🌍 Navigating to home page');
|
||||||
|
await world.page.goto(world.baseURL);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I am on the registration page', async function () {
|
||||||
|
console.log('🌍 Navigating to registration page');
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I am on the login page', async function () {
|
||||||
|
console.log('🌍 Navigating to login page');
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/login`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I am on the {string} page', async function (pageName: string) {
|
||||||
|
const pageUrls: Record<string, string> = {
|
||||||
|
'home': '/',
|
||||||
|
'home page': '/',
|
||||||
|
'registration': '/auth/register',
|
||||||
|
'registration page': '/auth/register',
|
||||||
|
'login': '/auth/login',
|
||||||
|
'login page': '/auth/login',
|
||||||
|
'rankings': '/rankings',
|
||||||
|
'rankings page': '/rankings',
|
||||||
|
'admin': '/admin',
|
||||||
|
'admin page': '/admin',
|
||||||
|
'admin dashboard': '/admin',
|
||||||
|
'tournaments': '/admin/tournaments',
|
||||||
|
'tournaments page': '/admin/tournaments',
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = pageUrls[pageName.toLowerCase()] || `/${pageName.toLowerCase().replace(' ', '-')}`;
|
||||||
|
|
||||||
|
console.log(`🌍 Navigating to ${pageName}: ${url}`);
|
||||||
|
await world.page.goto(`${world.baseURL}${url}`);
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I navigate to the {string} page', async function (pageName: string) {
|
||||||
|
// This step is a synonym for "I go to the {string} page"
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I navigate to the registration page', async function () {
|
||||||
|
console.log('🌍 Navigating to registration page');
|
||||||
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go to the home page', async function () {
|
||||||
|
console.log('🌍 Navigating to home page');
|
||||||
|
await world.page.goto(world.baseURL);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go to the {string} page', async function (pageName: string) {
|
||||||
|
const pageUrls: Record<string, string> = {
|
||||||
|
'home': '/',
|
||||||
|
'home page': '/',
|
||||||
|
'registration': '/auth/register',
|
||||||
|
'registration page': '/auth/register',
|
||||||
|
'login': '/auth/login',
|
||||||
|
'login page': '/auth/login',
|
||||||
|
'rankings': '/rankings',
|
||||||
|
'rankings page': '/rankings',
|
||||||
|
'admin': '/admin',
|
||||||
|
'admin page': '/admin',
|
||||||
|
'admin dashboard': '/admin',
|
||||||
|
'tournaments': '/admin/tournaments',
|
||||||
|
'tournaments page': '/admin/tournaments',
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = pageUrls[pageName.toLowerCase()] || `/${pageName.toLowerCase().replace(' ', '-')}`;
|
||||||
|
|
||||||
|
console.log(`🌍 Going to ${pageName}: ${url}`);
|
||||||
|
await world.page.goto(`${world.baseURL}${url}`);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go back', async function () {
|
||||||
|
await world.page.goBack();
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I refresh the page', async function () {
|
||||||
|
await world.page.reload();
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form interaction steps
|
||||||
|
*/
|
||||||
|
When('I fill in {string} with {string}', async function (fieldName: string, value: string) {
|
||||||
|
// Map field names to selectors
|
||||||
|
const fieldSelectors: Record<string, string> = {
|
||||||
|
'name': 'input[name="name"]',
|
||||||
|
'email': 'input[name="email"]',
|
||||||
|
'password': 'input[name="password"]',
|
||||||
|
'confirm password': 'input[name="confirmPassword"]',
|
||||||
|
'player name': 'input[name="playerName"]',
|
||||||
|
'tournament name': 'input[name="name"]',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selector = fieldSelectors[fieldName.toLowerCase()] || `input[name="${fieldName.toLowerCase()}"]`;
|
||||||
|
|
||||||
|
console.log(`🌍 Filling ${fieldName} with ${value}`);
|
||||||
|
await world.page.fill(selector, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I click the {string} button', async function (buttonText: string) {
|
||||||
|
const selector = `button:has-text("${buttonText}")`;
|
||||||
|
console.log(`🌍 Clicking button: ${buttonText}`);
|
||||||
|
await world.page.click(selector);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I click the {string} link', async function (linkText: string) {
|
||||||
|
const selector = `a:has-text("${linkText}")`;
|
||||||
|
console.log(`🌍 Clicking link: ${linkText}`);
|
||||||
|
await world.page.click(selector);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I click the {string} wordmark', async function (wordmarkText: string) {
|
||||||
|
const selector = `a:has-text("${wordmarkText}")`;
|
||||||
|
console.log(`🌍 Clicking wordmark: ${wordmarkText}`);
|
||||||
|
await world.page.click(selector);
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I click the {string} element', async function (elementText: string) {
|
||||||
|
const selector = `text=${elementText}`;
|
||||||
|
console.log(`🌍 Clicking element: ${elementText}`);
|
||||||
|
await world.page.click(selector);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assertion steps
|
||||||
|
*/
|
||||||
|
Then('I should see {string}', async function (text: string) {
|
||||||
|
console.log(`🌍 Checking for text: ${text}`);
|
||||||
|
await expect(world.page.locator(`text=${text}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should not see {string}', async function (text: string) {
|
||||||
|
console.log(`🌍 Checking text is NOT visible: ${text}`);
|
||||||
|
await expect(world.page.locator(`text=${text}`)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the {string} page', async function (pageName: string) {
|
||||||
|
const pageUrls: Record<string, string> = {
|
||||||
|
'home': '/',
|
||||||
|
'home page': '/',
|
||||||
|
'registration': '/auth/register',
|
||||||
|
'registration page': '/auth/register',
|
||||||
|
'login': '/auth/login',
|
||||||
|
'login page': '/auth/login',
|
||||||
|
'profile': '/players',
|
||||||
|
'admin': '/admin',
|
||||||
|
'admin page': '/admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedPath = pageUrls[pageName.toLowerCase()] || `/${pageName.toLowerCase().replace(' ', '-')}`;
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
console.log(`🌍 Expected path: ${expectedPath}`);
|
||||||
|
|
||||||
|
expect(currentUrl).toContain(expectedPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be redirected to my profile page', async function () {
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking redirect to profile page. Current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toMatch(/\/players\/\d+\/profile/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the registration page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toContain('/auth/register');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the login page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toContain('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the admin page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toContain('/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the home page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toContain('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the password reset page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toContain('/auth/password-reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see the {string} button', async function (buttonText: string) {
|
||||||
|
const button = world.page.locator(`button:has-text("${buttonText}")`);
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
console.log(`🌍 Verified button is visible: ${buttonText}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be redirected to {string}', async function (path: string) {
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking redirect to: ${path}`);
|
||||||
|
console.log(`🌍 Current URL: ${currentUrl}`);
|
||||||
|
|
||||||
|
expect(currentUrl).toContain(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element visibility steps
|
||||||
|
*/
|
||||||
|
Then('the element {string} should be visible', async function (elementText: string) {
|
||||||
|
const element = world.page.locator(`text=${elementText}`);
|
||||||
|
await expect(element).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the element {string} should not be visible', async function (elementText: string) {
|
||||||
|
const element = world.page.locator(`text=${elementText}`);
|
||||||
|
await expect(element).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait steps
|
||||||
|
*/
|
||||||
|
When('I wait for {int} seconds', async function (seconds: number) {
|
||||||
|
console.log(`🌍 Waiting ${seconds} seconds`);
|
||||||
|
await world.page.waitForTimeout(seconds * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I wait for the page to load', async function () {
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL verification steps
|
||||||
|
*/
|
||||||
|
Then('the URL should contain {string}', async function (expectedPath: string) {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
console.log(`🌍 Checking URL contains: ${expectedPath}`);
|
||||||
|
expect(currentUrl).toContain(expectedPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be redirected to the login page', async function () {
|
||||||
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
|
console.log(`🌍 Checking redirect to login page. Current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toContain('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see a {string} error', async function (errorMessage: string) {
|
||||||
|
const content = await world.page.content();
|
||||||
|
expect(content).toMatch(new RegExp(errorMessage, 'i'));
|
||||||
|
console.log(`🌍 Verified error message: ${errorMessage}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see {string} validation error', async function (field: string) {
|
||||||
|
// Look for validation error near the input field
|
||||||
|
const content = await world.page.content();
|
||||||
|
// Check if the field name is mentioned in an error context
|
||||||
|
expect(content).toMatch(new RegExp(field, 'i'));
|
||||||
|
console.log(`🌍 Verified validation error for: ${field}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see {string} error', async function (errorMessage: string) {
|
||||||
|
const content = await world.page.content();
|
||||||
|
expect(content).toMatch(new RegExp(errorMessage, 'i'));
|
||||||
|
console.log(`🌍 Verified error message: ${errorMessage}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Cucumber hooks for EuchreCamp E2E tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber';
|
||||||
|
import { chromium, Browser } from '@playwright/test';
|
||||||
|
import { world } from './world';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Global browser instance
|
||||||
|
let browser: Browser;
|
||||||
|
|
||||||
|
// Load environment files
|
||||||
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before all scenarios: Launch browser
|
||||||
|
*/
|
||||||
|
BeforeAll(async function () {
|
||||||
|
console.log('🌍 Launching browser for Cucumber tests...');
|
||||||
|
browser = await chromium.launch();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After all scenarios: Close browser and cleanup
|
||||||
|
*/
|
||||||
|
AfterAll(async function () {
|
||||||
|
console.log('🌍 Closing browser...');
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log('🌍 Disconnecting from database...');
|
||||||
|
if (world.prisma) {
|
||||||
|
await world.prisma.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before each scenario: Create new page context
|
||||||
|
*/
|
||||||
|
Before(async function () {
|
||||||
|
// Create new context and page
|
||||||
|
world.context = await browser.newContext();
|
||||||
|
world.page = await world.context.newPage();
|
||||||
|
|
||||||
|
// Log console messages for debugging
|
||||||
|
world.page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
console.log('❌ PAGE ERROR:', msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log network errors
|
||||||
|
world.page.on('requestfailed', request => {
|
||||||
|
console.log('❌ REQUEST FAILED:', request.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🌍 Created new page context');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After each scenario: Close page
|
||||||
|
*/
|
||||||
|
After(async function () {
|
||||||
|
console.log('🌍 Cleaning up after scenario...');
|
||||||
|
|
||||||
|
// Close page and context
|
||||||
|
if (world.page) {
|
||||||
|
await world.page.close();
|
||||||
|
}
|
||||||
|
if (world.context) {
|
||||||
|
await world.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for navigating to a page
|
||||||
|
*/
|
||||||
|
Before({ tags: '@navigation' }, async function () {
|
||||||
|
console.log('🌍 Navigation hook triggered');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for authenticated scenarios
|
||||||
|
*/
|
||||||
|
Before({ tags: '@authenticated' }, async function () {
|
||||||
|
console.log('🌍 Authenticated hook triggered');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag hooks for specific scenarios
|
||||||
|
*/
|
||||||
|
Before({ tags: '@slow' }, async function () {
|
||||||
|
console.log('🌍 Slow test - extending timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
Before({ tags: '@flaky' }, async function () {
|
||||||
|
console.log('🌍 Flaky test - retry enabled');
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* World context for Cucumber tests
|
||||||
|
* Provides shared state between step definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, BrowserContext, chromium, Browser } from '@playwright/test';
|
||||||
|
|
||||||
|
export interface WorldState {
|
||||||
|
page: Page;
|
||||||
|
context: BrowserContext;
|
||||||
|
prisma: any; // Lazy-loaded PrismaClient
|
||||||
|
baseURL: string;
|
||||||
|
user?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
tournament?: any;
|
||||||
|
tournamentTeamCount?: number;
|
||||||
|
player?: any;
|
||||||
|
match?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class World implements WorldState {
|
||||||
|
page!: Page;
|
||||||
|
context!: BrowserContext;
|
||||||
|
prisma: any;
|
||||||
|
baseURL: string;
|
||||||
|
user?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
tournament?: any;
|
||||||
|
tournamentTeamCount?: number;
|
||||||
|
player?: any;
|
||||||
|
match?: any;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
||||||
|
this.prisma = null; // Will be lazily loaded when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrisma() {
|
||||||
|
if (!this.prisma) {
|
||||||
|
// Lazily load PrismaClient when first accessed
|
||||||
|
const { PrismaClient } = await import('@prisma/client');
|
||||||
|
this.prisma = new PrismaClient();
|
||||||
|
}
|
||||||
|
return this.prisma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const world = new World();
|
||||||
@@ -5,10 +5,64 @@
|
|||||||
|
|
||||||
import { chromium, type FullConfig } from '@playwright/test';
|
import { chromium, type FullConfig } from '@playwright/test';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { cleanupAllTestData } from '@/__tests__/test-utils';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Load .env file first, then .env.development (which will override .env)
|
||||||
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
|
const envDevPath = path.resolve(process.cwd(), '.env.development');
|
||||||
|
|
||||||
|
// Load base .env file
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
require('dotenv').config({ path: envPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env.development file (will override .env settings)
|
||||||
|
if (fs.existsSync(envDevPath)) {
|
||||||
|
require('dotenv').config({ path: envDevPath, override: true });
|
||||||
|
}
|
||||||
|
|
||||||
const authFile = 'playwright/.auth/user.json';
|
const authFile = 'playwright/.auth/user.json';
|
||||||
const adminAuthFile = 'playwright/.auth/admin.json';
|
const adminAuthFile = 'playwright/.auth/admin.json';
|
||||||
|
|
||||||
|
// Check if we're using the dev database
|
||||||
|
function isDevDatabase(): boolean {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || '';
|
||||||
|
return dbUrl.includes('euchre_camp_dev');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProductionDatabase(): boolean {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || '';
|
||||||
|
return dbUrl.includes('euchre_camp') && !dbUrl.includes('_dev');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict check - fail if using production database
|
||||||
|
if (isProductionDatabase()) {
|
||||||
|
console.error('');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error('CRITICAL ERROR: Tests are attempting to run against PRODUCTION database!');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error('');
|
||||||
|
console.error('Current DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
console.error('');
|
||||||
|
console.error('Tests MUST run against the development database (euchre_camp_dev)');
|
||||||
|
console.error('');
|
||||||
|
console.error('To fix this:');
|
||||||
|
console.error(' 1. Run: npm run test:acceptance');
|
||||||
|
console.error(' 2. Or set: DATABASE_URL environment variable to dev database URL');
|
||||||
|
console.error(' 3. Or load .env.development: source .env.development && npm run test:acceptance');
|
||||||
|
console.error('');
|
||||||
|
console.error('Aborting test execution to prevent data corruption.');
|
||||||
|
console.error('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDevDatabase()) {
|
||||||
|
console.warn('⚠️ WARNING: DATABASE_URL does not contain euchre_camp_dev');
|
||||||
|
console.warn(' Current DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function globalSetup(config: FullConfig) {
|
export default async function globalSetup(config: FullConfig) {
|
||||||
const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:3000';
|
const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:3000';
|
||||||
|
|
||||||
@@ -144,18 +198,13 @@ export default async function globalSetup(config: FullConfig) {
|
|||||||
|
|
||||||
// Return teardown function
|
// Return teardown function
|
||||||
return async () => {
|
return async () => {
|
||||||
// Clean up test users
|
console.log('\n=== Global Teardown ===');
|
||||||
|
|
||||||
|
// Clean up all test data
|
||||||
try {
|
try {
|
||||||
await prisma.user.deleteMany({
|
await cleanupAllTestData();
|
||||||
where: {
|
|
||||||
email: {
|
|
||||||
startsWith: 'setup-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('Cleaned up test users');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cleaning up test users:', error);
|
console.error('Error cleaning up test data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createTestPlayer, cleanupTestRecords, getCreatedRecordCounts } from '@/__tests__/test-utils'
|
||||||
|
|
||||||
test.describe('Home Page', () => {
|
test.describe('Home Page', () => {
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
// Clean up any existing test records before each test
|
||||||
|
await cleanupTestRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
// Clean up test records after each test
|
||||||
|
await cleanupTestRecords();
|
||||||
|
});
|
||||||
|
|
||||||
test('should display top 10 players', async ({ page }) => {
|
test('should display top 10 players', async ({ page }) => {
|
||||||
// Create some test players with unique names and very high Elo to ensure they're in top 10
|
// Create some test players with unique names and very high Elo to ensure they're in top 10
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const players = []
|
const players = []
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const playerName = `Home Test Player ${timestamp} ${i + 1}`
|
const playerName = `Home Test Player ${timestamp} ${i + 1}`
|
||||||
const player = await prisma.player.create({
|
const player = await createTestPlayer({
|
||||||
data: {
|
|
||||||
name: playerName,
|
name: playerName,
|
||||||
normalizedName: playerName.toLowerCase(),
|
currentElo: 2000 - i * 10,
|
||||||
currentElo: 2000 - i * 10, // Very high Elo to ensure they're in top 10
|
|
||||||
gamesPlayed: 10 + i,
|
|
||||||
wins: 5 + Math.floor(i / 2),
|
|
||||||
losses: 5 + Math.ceil(i / 2),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
players.push(player)
|
players.push(player)
|
||||||
}
|
}
|
||||||
@@ -33,10 +38,9 @@ test.describe('Home Page', () => {
|
|||||||
page.locator(`a:has-text("Home Test Player ${timestamp} 1")`)
|
page.locator(`a:has-text("Home Test Player ${timestamp} 1")`)
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|
||||||
// Clean up
|
// Verify cleanup will work
|
||||||
for (const player of players) {
|
const counts = getCreatedRecordCounts();
|
||||||
await prisma.player.delete({ where: { id: player.id } })
|
expect(counts.players).toBe(3);
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should display club president', async ({ page }) => {
|
test('should display club president', async ({ page }) => {
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* E2E Test: Tournament Edit with allowTies
|
||||||
|
*
|
||||||
|
* User Story: As a tournament admin, I want to edit tournament settings including allowTies
|
||||||
|
*
|
||||||
|
* Acceptance Criteria:
|
||||||
|
* - Can edit tournament settings
|
||||||
|
* - allowTies checkbox can be toggled
|
||||||
|
* - allowTies value is saved correctly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
test.describe('Tournament Edit - allowTies functionality', () => {
|
||||||
|
let tournamentId: number;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Create a test tournament for editing
|
||||||
|
const tournament = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name: 'Test Tournament for AllowTies Edit',
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
allowTies: false,
|
||||||
|
targetScore: 5,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tournamentId = tournament.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
// Clean up test tournament
|
||||||
|
if (tournamentId) {
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display allowTies checkbox on edit form @chromium-admin', async ({ page }) => {
|
||||||
|
// Navigate to tournament edit page
|
||||||
|
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
|
||||||
|
|
||||||
|
// Wait for form to load
|
||||||
|
await expect(page.locator('text=Tournament Name')).toBeVisible();
|
||||||
|
|
||||||
|
// Check that allowTies checkbox exists
|
||||||
|
const allowTiesCheckbox = page.locator('input[name="allowTies"]');
|
||||||
|
await expect(allowTiesCheckbox).toBeVisible();
|
||||||
|
await expect(allowTiesCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should save allowTies when toggled to true @chromium-admin', async ({ page }) => {
|
||||||
|
// Navigate to tournament edit page
|
||||||
|
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
|
||||||
|
|
||||||
|
// Wait for form to load
|
||||||
|
await expect(page.locator('text=Tournament Name')).toBeVisible();
|
||||||
|
|
||||||
|
// Toggle allowTies checkbox
|
||||||
|
const allowTiesCheckbox = page.locator('input[name="allowTies"]');
|
||||||
|
await allowTiesCheckbox.check();
|
||||||
|
await expect(allowTiesCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for success message
|
||||||
|
await expect(page.locator('text=Tournament updated successfully')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify allowTies was saved by checking the database
|
||||||
|
const updatedTournament = await prisma.event.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedTournament?.allowTies).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should save allowTies when toggled to false @chromium-admin', async ({ page }) => {
|
||||||
|
// First, set allowTies to true
|
||||||
|
await prisma.event.update({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
data: { allowTies: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to tournament edit page
|
||||||
|
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
|
||||||
|
|
||||||
|
// Wait for form to load
|
||||||
|
await expect(page.locator('text=Tournament Name')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify checkbox is checked
|
||||||
|
const allowTiesCheckbox = page.locator('input[name="allowTies"]');
|
||||||
|
await expect(allowTiesCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Uncheck allowTies checkbox
|
||||||
|
await allowTiesCheckbox.uncheck();
|
||||||
|
await expect(allowTiesCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for success message
|
||||||
|
await expect(page.locator('text=Tournament updated successfully')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify allowTies was saved by checking the database
|
||||||
|
const updatedTournament = await prisma.event.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedTournament?.allowTies).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,8 +9,8 @@ set positional-arguments
|
|||||||
# Project name
|
# Project name
|
||||||
PROJECT := "euchre-camp"
|
PROJECT := "euchre-camp"
|
||||||
|
|
||||||
# Docker registry (CasaOS local registry)
|
# Docker registry
|
||||||
REGISTRY := "euchre-camp" # Update to your registry host if needed
|
REGISTRY := "docker.notsosm.art"
|
||||||
IMAGE_TAG := "latest"
|
IMAGE_TAG := "latest"
|
||||||
|
|
||||||
# Get git commit hash (short version)
|
# Get git commit hash (short version)
|
||||||
@@ -22,6 +22,8 @@ IMAGE_TAG_COMMIT := `git rev-parse --short HEAD`
|
|||||||
# --- Variables ---
|
# --- Variables ---
|
||||||
# Database
|
# Database
|
||||||
DB_CONTAINER := "euchre-camp-postgres"
|
DB_CONTAINER := "euchre-camp-postgres"
|
||||||
|
DATABASE_PROVIDER := env_var_or_default("DATABASE_PROVIDER", "sqlite")
|
||||||
|
DATABASE_URL := env_var_or_default("DATABASE_URL", "file:./prisma/dev.db")
|
||||||
|
|
||||||
# --- Setup & Installation ---
|
# --- Setup & Installation ---
|
||||||
|
|
||||||
@@ -54,15 +56,24 @@ format:
|
|||||||
|
|
||||||
# --- Testing ---
|
# --- Testing ---
|
||||||
|
|
||||||
# Run all tests (unit + acceptance)
|
# Run all tests (unit + acceptance with SQLite)
|
||||||
test: test-unit test-acceptance
|
# Note: Uses Docker containers for consistent environment
|
||||||
|
test: test-unit test-acceptance-sqlite
|
||||||
|
|
||||||
|
# Run all tests with PostgreSQL (Docker)
|
||||||
|
test-pg: test-unit test-acceptance-postgres
|
||||||
|
|
||||||
# Run unit tests (Vitest)
|
# Run unit tests (Vitest)
|
||||||
test-unit:
|
test-unit:
|
||||||
npm run test:run
|
npm run test:run
|
||||||
|
|
||||||
# Run acceptance tests (Playwright)
|
# Run acceptance tests with SQLite (fast, no Docker needed)
|
||||||
test-acceptance:
|
test-acceptance-sqlite:
|
||||||
|
@echo "Running acceptance tests with SQLite..."
|
||||||
|
DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db BETTER_AUTH_SECRET=test-secret-key npm run test:acceptance
|
||||||
|
|
||||||
|
# Run acceptance tests with PostgreSQL (Docker)
|
||||||
|
test-acceptance-postgres:
|
||||||
@echo "Starting Docker containers for acceptance tests..."
|
@echo "Starting Docker containers for acceptance tests..."
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@echo "Waiting for services to be ready..."
|
@echo "Waiting for services to be ready..."
|
||||||
@@ -80,6 +91,13 @@ migrate:
|
|||||||
seed:
|
seed:
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
|
|
||||||
|
# Switch database provider
|
||||||
|
db-switch-sqlite:
|
||||||
|
npm run db:switch sqlite
|
||||||
|
|
||||||
|
db-switch-postgres:
|
||||||
|
npm run db:switch postgresql
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
|
|
||||||
# Build the Docker image (standard build)
|
# Build the Docker image (standard build)
|
||||||
@@ -143,9 +161,18 @@ docker-push: docker-build-full
|
|||||||
# --- CI/CD Pipeline Simulation ---
|
# --- CI/CD Pipeline Simulation ---
|
||||||
|
|
||||||
# Run full CI pipeline locally (lint, test, build, push)
|
# Run full CI pipeline locally (lint, test, build, push)
|
||||||
ci: lint typecheck test-unit docker-build
|
# Matches the Gitea Actions workflow
|
||||||
|
ci: lint typecheck test-unit test-acceptance-sqlite docker-build
|
||||||
@echo "CI Pipeline completed successfully!"
|
@echo "CI Pipeline completed successfully!"
|
||||||
|
|
||||||
|
# PR validation (what runs on pull requests)
|
||||||
|
pr-validate: lint typecheck test-unit test-acceptance-sqlite
|
||||||
|
@echo "PR validation completed successfully!"
|
||||||
|
|
||||||
|
# Run CI with PostgreSQL (for release workflow simulation)
|
||||||
|
ci-postgres: lint typecheck test-unit test-acceptance-postgres docker-build
|
||||||
|
@echo "CI Pipeline with PostgreSQL completed successfully!"
|
||||||
|
|
||||||
# --- Utilities ---
|
# --- Utilities ---
|
||||||
|
|
||||||
# Show help information
|
# Show help information
|
||||||
@@ -158,3 +185,74 @@ clean:
|
|||||||
rm -rf node_modules .next dist
|
rm -rf node_modules .next dist
|
||||||
@echo "Cleaning Docker artifacts..."
|
@echo "Cleaning Docker artifacts..."
|
||||||
docker system prune -f
|
docker system prune -f
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
prisma-generate:
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Reset development database
|
||||||
|
db-reset-dev:
|
||||||
|
npm run db:reset-dev
|
||||||
|
|
||||||
|
# Setup development database
|
||||||
|
db-setup-dev:
|
||||||
|
npm run db:setup-dev
|
||||||
|
|
||||||
|
# Clean production database (remove test records)
|
||||||
|
db-clean-prod:
|
||||||
|
npm run db:cleanup-prod
|
||||||
|
|
||||||
|
# Check production database for test records
|
||||||
|
db-check-prod:
|
||||||
|
npm run db:check-prod
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
admin-create:
|
||||||
|
node scripts/create-admin-via-api.js
|
||||||
|
|
||||||
|
# List all users
|
||||||
|
users-list:
|
||||||
|
node scripts/list-users.js
|
||||||
|
|
||||||
|
# Update admin password
|
||||||
|
admin-update-password:
|
||||||
|
node scripts/update-admin-password.js
|
||||||
|
|
||||||
|
# Bump version
|
||||||
|
version-bump-patch:
|
||||||
|
npm run version:patch
|
||||||
|
|
||||||
|
version-bump-minor:
|
||||||
|
npm run version:minor
|
||||||
|
|
||||||
|
version-bump-major:
|
||||||
|
npm run version:major
|
||||||
|
|
||||||
|
# Docker Compose shortcuts
|
||||||
|
docker-up:
|
||||||
|
npm run docker:up
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
npm run docker:down
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
npm run docker:logs
|
||||||
|
|
||||||
|
# View workflow status
|
||||||
|
workflow-status:
|
||||||
|
@echo "Current workflows in .gitea/workflows/:"
|
||||||
|
ls -la .gitea/workflows/
|
||||||
|
@echo ""
|
||||||
|
@echo "PR Workflow: Runs unit + acceptance tests on pull requests"
|
||||||
|
@echo "Test Workflow: Runs unit tests on all branch pushes"
|
||||||
|
@echo "Release Workflow: Runs on main branch pushes (version bump + Docker build)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Note: CI image approach deprecated due to Gitea Actions workspace mounting"
|
||||||
|
|
||||||
|
# Check current database provider
|
||||||
|
db-status:
|
||||||
|
@echo "Database Provider: ${DATABASE_PROVIDER}"
|
||||||
|
@echo "Database URL: ${DATABASE_URL}"
|
||||||
|
@echo ""
|
||||||
|
@echo "Current schema.prisma provider:"
|
||||||
|
grep -A 2 "datasource db" prisma/schema.prisma | head -3
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[tools]
|
[tools]
|
||||||
|
bun = "latest"
|
||||||
docker-compose = "latest"
|
docker-compose = "latest"
|
||||||
just = "latest"
|
just = "latest"
|
||||||
node = "latest"
|
node = "latest"
|
||||||
|
|||||||
+36
-18
@@ -1,32 +1,45 @@
|
|||||||
{
|
{
|
||||||
"name": "euchre_camp",
|
"name": "euchre_camp",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
|
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "bun run eslint",
|
||||||
"test": "vitest",
|
"test": "bun test 'src/__tests__/unit/**' 'src/__tests__/*.test.tsx' 'src/__tests__/auth-simple.test.ts'",
|
||||||
"test:run": "vitest run",
|
"test:unit": "bun test src/__tests__/unit/",
|
||||||
"test:acceptance": "playwright test src/__tests__/e2e/",
|
"test:component": "bun test src/__tests__/*.test.tsx",
|
||||||
"test:acceptance:headed": "playwright test src/__tests__/e2e/ --headed",
|
"test:run": "bun test 'src/__tests__/unit/**' 'src/__tests__/*.test.tsx' 'src/__tests__/auth-simple.test.ts'",
|
||||||
"db:switch": "node scripts/switch-database.js",
|
"test:randomize": "bun test src/__tests__/unit/ --randomize",
|
||||||
"db:setup-postgres": "node scripts/setup-postgres.js",
|
"test:unit:sequential": "bun test src/__tests__/unit/ --max-concurrency=1",
|
||||||
"db:seed": "node scripts/seed.js",
|
"test:acceptance": "bun x playwright test e2e/",
|
||||||
|
"test:acceptance:headed": "bun x playwright test e2e/ --headed",
|
||||||
|
"test:acceptance:cucumber": "bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
|
||||||
|
"test:acceptance:cucumber:pretty": "bun cucumber-js --config e2e/cucumber/cucumber.config.ts --format pretty:cucumber-pretty",
|
||||||
|
"cucumber": "bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
|
||||||
|
"db:switch": "bun run scripts/switch-database.js",
|
||||||
|
"db:setup-postgres": "bun run scripts/setup-postgres.js",
|
||||||
|
"db:setup-dev": "bun run scripts/setup-postgres.js",
|
||||||
|
"db:setup-dev:clean": "bun run scripts/setup-postgres.js --drop",
|
||||||
|
"db:reset-dev": "bun run scripts/reset-dev-db.js",
|
||||||
|
"db:use-dev": "bun run scripts/use-dev-db.js",
|
||||||
|
"db:cleanup-prod": "bun run scripts/cleanup-prod-db.js",
|
||||||
|
"db:check-prod": "bun run scripts/check-test-records.js",
|
||||||
|
"db:seed": "bun run scripts/seed.js",
|
||||||
"docker:up": "docker-compose up -d",
|
"docker:up": "docker-compose up -d",
|
||||||
"docker:down": "docker-compose down",
|
"docker:down": "docker-compose down",
|
||||||
"docker:logs": "docker-compose logs -f",
|
"docker:logs": "docker-compose logs -f",
|
||||||
"docker:build": "docker-compose build",
|
"docker:build": "docker-compose build",
|
||||||
"docker:shell": "docker exec -it euchre-camp-app sh",
|
"docker:shell": "docker exec -it euchre-camp-app sh",
|
||||||
"version": "node scripts/bump-version.js",
|
"version": "bun run scripts/bump-version.js",
|
||||||
"set-version": "node scripts/set-version.js",
|
"set-version": "bun run scripts/set-version.js",
|
||||||
"version:patch": "node scripts/bump-version.js patch",
|
"version:patch": "bun run scripts/bump-version.js patch",
|
||||||
"version:minor": "node scripts/bump-version.js minor",
|
"version:minor": "bun run scripts/bump-version.js minor",
|
||||||
"version:major": "node scripts/bump-version.js major",
|
"version:major": "bun run scripts/bump-version.js major",
|
||||||
"docker:build:push": "node scripts/build-and-push-docker.js",
|
"docker:build:push": "bun run scripts/build-and-push-docker.js",
|
||||||
"docker:compose:generate": "node scripts/generate-docker-compose.js",
|
"docker:compose:generate": "bun run scripts/generate-docker-compose.js",
|
||||||
"release": "npm run version:patch && npm run docker:compose:generate && npm run docker:build:push"
|
"release": "bun run version:patch && bun run docker:compose:generate && bun run docker:build:push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -49,12 +62,15 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cucumber/cucumber": "^12.8.2",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/bun": "^1.3.11",
|
||||||
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
@@ -62,10 +78,12 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"eslint": "^10.1.0",
|
"cucumber-pretty": "^6.0.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "^16.2.1",
|
"eslint-config-next": "^16.2.1",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './src/__tests__/e2e',
|
testDir: './e2e',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
|||||||
// Reporter to use
|
// Reporter to use
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
// Global setup and teardown
|
// Global setup and teardown
|
||||||
globalSetup: require.resolve('./src/__tests__/e2e/global.setup'),
|
globalSetup: require.resolve('./e2e/global.setup'),
|
||||||
// Use base URL for relative navigation
|
// Use base URL for relative navigation
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: 'http://localhost:3000',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const REGISTRY = 'dhg.lol:5000';
|
const REGISTRY = 'docker.notsosm.art';
|
||||||
const IMAGE_NAME = 'euchre-camp';
|
const IMAGE_NAME = 'euchre-camp';
|
||||||
const FULL_IMAGE_NAME = `${REGISTRY}/${IMAGE_NAME}`;
|
const FULL_IMAGE_NAME = `${REGISTRY}/${IMAGE_NAME}`;
|
||||||
|
|
||||||
|
|||||||
+28
-9
@@ -17,6 +17,7 @@ const path = require('path');
|
|||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const forcedVersion = args[0]; // e.g., "0.2.0" or "patch/minor/major"
|
const forcedVersion = args[0]; // e.g., "0.2.0" or "patch/minor/major"
|
||||||
|
const skipConfirm = args.includes('--yes') || args.includes('-y'); // Skip confirmation prompt
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||||
@@ -47,14 +48,22 @@ function getCommitsSinceLastTag() {
|
|||||||
|
|
||||||
if (!latestTag) {
|
if (!latestTag) {
|
||||||
// No tags yet, get all commits
|
// No tags yet, get all commits
|
||||||
return execSync('git log --oneline --format=%s', { encoding: 'utf8' }).trim().split('\n');
|
const commits = execSync('git log --oneline --format=%s', { encoding: 'utf8' }).trim();
|
||||||
|
return commits ? commits.split('\n') : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const commits = execSync(`git log ${latestTag}..HEAD --oneline --format=%s`, { encoding: 'utf8' }).trim();
|
const commits = execSync(`git log ${latestTag}..HEAD --oneline --format=%s`, { encoding: 'utf8' }).trim();
|
||||||
return commits ? commits.split('\n') : [];
|
return commits ? commits.split('\n') : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If no commits since tag or other error, get recent commits
|
// If no commits since tag or other error, get recent commits
|
||||||
return execSync('git log --oneline --format=%s -n 20', { encoding: 'utf8' }).trim().split('\n');
|
try {
|
||||||
|
const commits = execSync('git log --oneline --format=%s -n 20', { encoding: 'utf8' }).trim();
|
||||||
|
return commits ? commits.split('\n') : [];
|
||||||
|
} catch (innerError) {
|
||||||
|
// If all git log attempts fail, return empty array
|
||||||
|
console.warn('Warning: Could not retrieve commit history, defaulting to patch version');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +183,20 @@ function main() {
|
|||||||
console.log(`New version: ${currentVersion} → ${newVersion}`);
|
console.log(`New version: ${currentVersion} → ${newVersion}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm with user
|
// Confirm with user (or skip if --yes flag is set)
|
||||||
|
if (skipConfirm) {
|
||||||
|
// Update package.json
|
||||||
|
updatePackageJson(newVersion);
|
||||||
|
|
||||||
|
// Update changelog
|
||||||
|
if (bumpType !== 'custom') {
|
||||||
|
const commits = getCommitsSinceLastTag();
|
||||||
|
updateChangelog(newVersion, commits, bumpType);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Version bumped to ${newVersion}`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
@@ -193,18 +215,15 @@ function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n✅ Version bumped to ${newVersion}`);
|
console.log(`\n✅ Version bumped to ${newVersion}`);
|
||||||
console.log(`\nNext steps:`);
|
process.exit(0);
|
||||||
console.log(` 1. Review changes: git diff`);
|
|
||||||
console.log(` 2. Commit changes: git commit -am "chore: bump version to v${newVersion}"`);
|
|
||||||
console.log(` 3. Create tag: git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
||||||
console.log(` 4. Push changes: git push origin main`);
|
|
||||||
console.log(` 5. Push tag: git push origin v${newVersion}`);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Version bump cancelled');
|
console.log('❌ Version bump cancelled');
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
rl.close();
|
rl.close();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run main function
|
// Run main function
|
||||||
|
|||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for test records in production database (read-only)
|
||||||
|
* Shows what would be deleted without making any changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Database connection string - must be explicitly set via environment variable
|
||||||
|
const DB_URL = process.env.PROD_DATABASE_URL || process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
// Check if database URL is set
|
||||||
|
if (!DB_URL) {
|
||||||
|
console.error('❌ No database URL set');
|
||||||
|
console.error('Please set PROD_DATABASE_URL or DATABASE_URL environment variable');
|
||||||
|
console.error('For production: PROD_DATABASE_URL="postgresql://user:pass@host:port/dbname"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we're not accidentally using dev database
|
||||||
|
if (DB_URL.includes('_dev') || DB_URL.includes('_dev_')) {
|
||||||
|
console.error('❌ ERROR: This script should not be used with the development database!');
|
||||||
|
console.error(' Current DATABASE_URL:', DB_URL);
|
||||||
|
console.error(' Please set PROD_DATABASE_URL for production database operations.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to run SQL and log results
|
||||||
|
function runSQL(sql, description) {
|
||||||
|
console.log(`\n🔍 ${description}...`);
|
||||||
|
try {
|
||||||
|
const result = execSync(`psql "${DB_URL}" -c "${sql}"`, { encoding: 'utf8' });
|
||||||
|
console.log(result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to count records matching pattern
|
||||||
|
function countRecords(table, column, pattern) {
|
||||||
|
const sql = `SELECT COUNT(*) FROM ${table} WHERE ${column} LIKE '${pattern}';`;
|
||||||
|
try {
|
||||||
|
const result = execSync(`psql "${DB_URL}" -c "${sql}" -t`, { encoding: 'utf8' }).trim();
|
||||||
|
return parseInt(result);
|
||||||
|
} catch (error) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 Checking test records in production database');
|
||||||
|
console.log('=============================================\n');
|
||||||
|
|
||||||
|
// Count test players
|
||||||
|
const testPlayerCount = countRecords('players', 'name', '%Test%') +
|
||||||
|
countRecords('players', 'name', '%Setup%') +
|
||||||
|
countRecords('players', 'name', '%Home Test%') +
|
||||||
|
countRecords('players', 'name', '%Home Match Player%') +
|
||||||
|
countRecords('players', 'name', '%Admin User%');
|
||||||
|
console.log(`Test players found: ${testPlayerCount}`);
|
||||||
|
|
||||||
|
// Count test tournaments
|
||||||
|
const testEventCount = countRecords('events', 'name', '%Test%') +
|
||||||
|
countRecords('events', 'name', '%Setup%') +
|
||||||
|
countRecords('events', 'name', '%Recent%');
|
||||||
|
console.log(`Test tournaments found: ${testEventCount}`);
|
||||||
|
|
||||||
|
// Count test users
|
||||||
|
const testUserCount = countRecords('users', 'email', '%test%') +
|
||||||
|
countRecords('users', 'email', '%setup%');
|
||||||
|
console.log(`Test users found: ${testUserCount}`);
|
||||||
|
|
||||||
|
// Show test players
|
||||||
|
runSQL(`
|
||||||
|
SELECT id, name
|
||||||
|
FROM players
|
||||||
|
WHERE name LIKE '%Test%' OR name LIKE '%Setup%' OR name LIKE '%Home Test%' OR name LIKE '%Home Match Player%' OR name LIKE '%Admin User%'
|
||||||
|
ORDER BY name;
|
||||||
|
`, 'Test players list');
|
||||||
|
|
||||||
|
// Show test events
|
||||||
|
runSQL(`
|
||||||
|
SELECT id, name
|
||||||
|
FROM events
|
||||||
|
WHERE name LIKE '%Test%' OR name LIKE '%Setup%' OR name LIKE '%Recent%'
|
||||||
|
ORDER BY name;
|
||||||
|
`, 'Test events list');
|
||||||
|
|
||||||
|
// Show test users
|
||||||
|
runSQL(`
|
||||||
|
SELECT id, email, name
|
||||||
|
FROM users
|
||||||
|
WHERE email LIKE '%test%' OR email LIKE '%setup%'
|
||||||
|
ORDER BY email;
|
||||||
|
`, 'Test users list');
|
||||||
|
|
||||||
|
// Check which test players have matches (simplified query)
|
||||||
|
runSQL(`
|
||||||
|
SELECT p.id, p.name
|
||||||
|
FROM players p
|
||||||
|
WHERE (p.name LIKE '%Test%' OR p.name LIKE '%Setup%' OR p.name LIKE '%Home Test%' OR p.name LIKE '%Home Match Player%' OR p.name LIKE '%Admin User%')
|
||||||
|
ORDER BY p.name;
|
||||||
|
`, 'Test players (will be deleted)');
|
||||||
|
|
||||||
|
// Check which test events exist
|
||||||
|
runSQL(`
|
||||||
|
SELECT e.id, e.name
|
||||||
|
FROM events e
|
||||||
|
WHERE (e.name LIKE '%Test%' OR e.name LIKE '%Setup%' OR e.name LIKE '%Recent%')
|
||||||
|
ORDER BY e.name;
|
||||||
|
`, 'Test events (will be deleted with matches via cascade)');
|
||||||
|
|
||||||
|
console.log('\n✅ Check complete!');
|
||||||
|
console.log('\n💡 Summary:');
|
||||||
|
console.log(' - Test tournaments (with matches) can be deleted');
|
||||||
|
console.log(' - Test players can be deleted');
|
||||||
|
console.log(' - Test users without player associations can be deleted');
|
||||||
Executable
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up production database - remove test records only
|
||||||
|
*
|
||||||
|
* WARNING: This script modifies the production database.
|
||||||
|
* It only removes records that match test patterns.
|
||||||
|
* It preserves actual player data, matches, and tournaments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Database connection string - must be explicitly set via environment variable
|
||||||
|
const DB_URL = process.env.PROD_DATABASE_URL || process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
// Check if database URL is set
|
||||||
|
if (!DB_URL) {
|
||||||
|
console.error('❌ No database URL set');
|
||||||
|
console.error('Please set PROD_DATABASE_URL or DATABASE_URL environment variable');
|
||||||
|
console.error('For production: PROD_DATABASE_URL="postgresql://user:pass@host:port/dbname"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we're not accidentally using dev database
|
||||||
|
if (DB_URL.includes('_dev') || DB_URL.includes('_dev_')) {
|
||||||
|
console.error('❌ ERROR: This script should not be used with the development database!');
|
||||||
|
console.error(' Current DATABASE_URL:', DB_URL);
|
||||||
|
console.error(' Please set PROD_DATABASE_URL for production database operations.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to run SQL and log results
|
||||||
|
function runSQL(sql, description) {
|
||||||
|
console.log(`\n🔍 ${description}...`);
|
||||||
|
try {
|
||||||
|
const result = execSync(`psql "${DB_URL}" -c "${sql}"`, { encoding: 'utf8' });
|
||||||
|
console.log(result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to run multi-line SQL
|
||||||
|
function runMultiLineSQL(sqlLines, description) {
|
||||||
|
console.log(`\n🔍 ${description}...`);
|
||||||
|
try {
|
||||||
|
const sql = sqlLines.join('\n');
|
||||||
|
const result = execSync(`psql "${DB_URL}" -c "${sql}"`, { encoding: 'utf8' });
|
||||||
|
console.log(result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to count records matching pattern
|
||||||
|
function countRecords(table, column, pattern) {
|
||||||
|
const sql = `SELECT COUNT(*) FROM ${table} WHERE ${column} LIKE '${pattern}';`;
|
||||||
|
try {
|
||||||
|
const result = execSync(`psql "${DB_URL}" -c "${sql}" -t`, { encoding: 'utf8' }).trim();
|
||||||
|
return parseInt(result);
|
||||||
|
} catch (error) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🧹 Cleaning up test records from production database');
|
||||||
|
console.log('=============================================\n');
|
||||||
|
|
||||||
|
// Confirm with user
|
||||||
|
const readline = require('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.question('⚠️ This will DELETE test records from PRODUCTION database. Continue? (y/N) ', async (answer) => {
|
||||||
|
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
||||||
|
console.log('❌ Cleanup cancelled');
|
||||||
|
rl.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
console.log('\n📋 Checking test records...\n');
|
||||||
|
|
||||||
|
// Count test players
|
||||||
|
const testPlayerCount = countRecords('players', 'name', '%Test%') +
|
||||||
|
countRecords('players', 'name', '%Setup%') +
|
||||||
|
countRecords('players', 'name', '%Home Test%') +
|
||||||
|
countRecords('players', 'name', '%Home Match Player%') +
|
||||||
|
countRecords('players', 'name', '%Admin User%');
|
||||||
|
console.log(`Test players found: ${testPlayerCount}`);
|
||||||
|
|
||||||
|
// Count test tournaments
|
||||||
|
const testEventCount = countRecords('events', 'name', '%Test%') +
|
||||||
|
countRecords('events', 'name', '%Setup%') +
|
||||||
|
countRecords('events', 'name', '%Recent%');
|
||||||
|
console.log(`Test tournaments found: ${testEventCount}`);
|
||||||
|
|
||||||
|
// Count test users
|
||||||
|
const testUserCount = countRecords('users', 'email', '%test%') +
|
||||||
|
countRecords('users', 'email', '%setup%');
|
||||||
|
console.log(`Test users found: ${testUserCount}`);
|
||||||
|
|
||||||
|
console.log('\n🔄 Starting cleanup...\n');
|
||||||
|
|
||||||
|
// Step 1: Delete test tournaments (with matches due to cascade delete)
|
||||||
|
console.log('1. Deleting test tournaments (matches will be deleted via cascade)...');
|
||||||
|
runSQL(
|
||||||
|
"DELETE FROM events WHERE (name LIKE '%Test%' OR name LIKE '%Setup%' OR name LIKE '%Recent%');",
|
||||||
|
'Deleted test tournaments'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Delete test players (all of them, since we deleted their matches)
|
||||||
|
console.log('\n2. Deleting test players...');
|
||||||
|
runSQL(
|
||||||
|
"DELETE FROM players WHERE (name LIKE '%Test%' OR name LIKE '%Setup%' OR name LIKE '%Home Test%' OR name LIKE '%Home Match Player%' OR name LIKE '%Admin User%');",
|
||||||
|
'Deleted test players'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Delete test users (they shouldn't have player associations)
|
||||||
|
console.log('\n3. Deleting test users...');
|
||||||
|
runSQL(
|
||||||
|
"DELETE FROM users WHERE (email LIKE '%test%' OR email LIKE '%setup%') AND \"playerId\" IS NULL;",
|
||||||
|
'Deleted test users'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n✅ Cleanup complete!');
|
||||||
|
console.log('\n📊 Remaining test records:');
|
||||||
|
runSQL("SELECT COUNT(*) FROM players WHERE name LIKE '%Test%' OR name LIKE '%Setup%' OR name LIKE '%Home Test%';", 'Remaining test players');
|
||||||
|
runSQL("SELECT COUNT(*) FROM events WHERE name LIKE '%Test%' OR name LIKE '%Setup%';", 'Remaining test tournaments');
|
||||||
|
runSQL("SELECT COUNT(*) FROM users WHERE email LIKE '%test%' OR email LIKE '%setup%';", 'Remaining test users');
|
||||||
|
|
||||||
|
console.log('\n💡 Note: All test records deleted. Matches are automatically deleted');
|
||||||
|
console.log(' via cascade delete when their parent tournament is deleted.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run main function
|
||||||
|
main();
|
||||||
@@ -43,8 +43,17 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log('User exists, deleting...');
|
console.log('User exists, updating role to site_admin...');
|
||||||
await prisma.user.delete({ where: { id: existing.id } });
|
await prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { role: 'site_admin' }
|
||||||
|
});
|
||||||
|
console.log('✅ User role updated to site_admin (superuser)');
|
||||||
|
console.log('\nSuperuser created successfully!');
|
||||||
|
console.log('Email: david@dhg.lol');
|
||||||
|
console.log('Password: adminadmin');
|
||||||
|
console.log('Role: site_admin');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user using Better Auth's internal method
|
// Create user using Better Auth's internal method
|
||||||
@@ -62,13 +71,13 @@ async function main() {
|
|||||||
console.log(' Name:', signUpResult.user.name);
|
console.log(' Name:', signUpResult.user.name);
|
||||||
console.log(' Password: adminadmin');
|
console.log(' Password: adminadmin');
|
||||||
|
|
||||||
// Update the user to have club_admin role
|
// Update the user to have site_admin role (superuser)
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: signUpResult.user.id },
|
where: { id: signUpResult.user.id },
|
||||||
data: { role: 'club_admin' }
|
data: { role: 'site_admin' }
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ User role updated to club_admin');
|
console.log('✅ User role updated to site_admin (superuser)');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error.message);
|
console.error('❌ Error:', error.message);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const path = require('path');
|
|||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const REGISTRY = 'dhg.lol:5000';
|
const REGISTRY = 'docker.notsosm.art';
|
||||||
const IMAGE_NAME = 'euchre-camp';
|
const IMAGE_NAME = 'euchre-camp';
|
||||||
|
|
||||||
// Get current version from package.json
|
// Get current version from package.json
|
||||||
@@ -64,8 +64,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Database Configuration (REQUIRED: Set via CasaOS environment variables)
|
# Database Configuration (REQUIRED: Set via CasaOS environment variables)
|
||||||
- DATABASE_PROVIDER=postgresql
|
- DATABASE_PROVIDER=postgresql
|
||||||
- DATABASE_URL="postgresql://euchre_camp:LINGO5row_hiding@dhg.lol:5432/euchre_camp"
|
- DATABASE_URL=\${DATABASE_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp}
|
||||||
- DATABASE_SHADOW_URL="postgresql://euchre_camp:LINGO5row_hiding@dhg.lol:5432/euchre_camp_shadow"
|
- DATABASE_SHADOW_URL=\${DATABASE_SHADOW_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp_shadow}
|
||||||
|
|
||||||
# Better Auth Configuration
|
# Better Auth Configuration
|
||||||
- BETTER_AUTH_SECRET=\${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
- BETTER_AUTH_SECRET=\${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
||||||
@@ -113,8 +113,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
- DATABASE_PROVIDER=postgresql
|
- DATABASE_PROVIDER=postgresql
|
||||||
- DATABASE_URL=postgresql://euchre_camp:password@db:5432/euchre_camp
|
- DATABASE_URL=\${DATABASE_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp}
|
||||||
- DATABASE_SHADOW_URL=postgresql://euchre_camp:password@db:5432/euchre_camp_shadow
|
- DATABASE_SHADOW_URL=\${DATABASE_SHADOW_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp_shadow}
|
||||||
|
|
||||||
# Better Auth Configuration
|
# Better Auth Configuration
|
||||||
- BETTER_AUTH_SECRET=\${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
- BETTER_AUTH_SECRET=\${BETTER_AUTH_SECRET:-change-this-secret-in-production}
|
||||||
@@ -181,8 +181,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
- DATABASE_PROVIDER=postgresql
|
- DATABASE_PROVIDER=postgresql
|
||||||
- DATABASE_URL=postgresql://euchre_camp:password@db:5432/euchre_camp
|
- DATABASE_URL=\${DATABASE_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp}
|
||||||
- DATABASE_SHADOW_URL=postgresql://euchre_camp:password@db:5432/euchre_camp_shadow
|
- DATABASE_SHADOW_URL=\${DATABASE_SHADOW_URL:-postgresql://euchre_camp:password@db:5432/euchre_camp_shadow}
|
||||||
|
|
||||||
# Better Auth Configuration
|
# Better Auth Configuration
|
||||||
- BETTER_AUTH_SECRET=\${BETTER_AUTH_SECRET:-dev-secret-change-in-production}
|
- BETTER_AUTH_SECRET=\${BETTER_AUTH_SECRET:-dev-secret-change-in-production}
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset Development Database
|
||||||
|
*
|
||||||
|
* Drops and recreates the development database with a clean state.
|
||||||
|
* Useful for development and testing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Load environment variables - prioritize explicit DEV_DATABASE_URL
|
||||||
|
// Falls back to DATABASE_URL, then .env.development
|
||||||
|
const envPath = path.resolve(__dirname, '..', '.env.development');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
require('dotenv').config({ path: envPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use DEV_DATABASE_URL if set, otherwise use DATABASE_URL
|
||||||
|
const databaseUrl = process.env.DEV_DATABASE_URL || process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
// Check if database URL is set
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error('❌ No database URL set');
|
||||||
|
console.error('Please set DEV_DATABASE_URL or DATABASE_URL environment variable');
|
||||||
|
console.error('Or ensure .env.development file exists with DATABASE_URL');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we're using the dev database
|
||||||
|
if (!databaseUrl.includes('_dev') && !databaseUrl.includes('_dev_')) {
|
||||||
|
console.error('❌ ERROR: This script should only be used with the development database!');
|
||||||
|
console.error(' Current URL:', databaseUrl);
|
||||||
|
console.error(' Expected pattern: euchre_camp_dev');
|
||||||
|
console.error(' Please set DEV_DATABASE_URL for development database operations.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract database name from URL
|
||||||
|
const dbUrl = databaseUrl;
|
||||||
|
const dbName = dbUrl.split('/').pop();
|
||||||
|
|
||||||
|
console.log('🔄 Resetting development database...\n');
|
||||||
|
console.log(`Database: ${dbName}`);
|
||||||
|
console.log(`URL: ${dbUrl}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drop existing connections
|
||||||
|
console.log('Dropping existing connections...');
|
||||||
|
execSync(
|
||||||
|
`psql "${dbUrl}" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${dbName}' AND pid <> pg_backend_pid();" 2>/dev/null || true`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop the database
|
||||||
|
console.log('Dropping database...');
|
||||||
|
execSync(
|
||||||
|
`psql "${dbUrl}" -c "DROP DATABASE IF EXISTS ${dbName};" 2>/dev/null || true`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recreate the database
|
||||||
|
console.log('Creating database...');
|
||||||
|
execSync(`psql "${dbUrl}" -c "CREATE DATABASE ${dbName};"`, { stdio: 'pipe' });
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
console.log('Running migrations...');
|
||||||
|
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
|
||||||
|
|
||||||
|
console.log('\n✅ Development database reset successfully!');
|
||||||
|
console.log('\nNext steps:');
|
||||||
|
console.log('1. Seed the database (optional): npm run db:seed');
|
||||||
|
console.log('2. Start development server: npm run dev');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Failed to reset database:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run tests in a Node.js container (for consistent environment)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running tests in node:20-alpine container..."
|
||||||
|
echo "This avoids Node.js setup time and ensures consistent environment."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run unit tests in container
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd):/app" \
|
||||||
|
-w /app \
|
||||||
|
node:20-alpine \
|
||||||
|
sh -c "apk add --no-cache bash git && npm ci && npm run test:run"
|
||||||
@@ -9,8 +9,12 @@ const { execSync } = require('child_process');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// Load .env file if it exists
|
// Load .env.development file first (if it exists), then .env file
|
||||||
|
const envDevPath = path.resolve(__dirname, '..', '.env.development');
|
||||||
const envPath = path.resolve(__dirname, '..', '.env');
|
const envPath = path.resolve(__dirname, '..', '.env');
|
||||||
|
if (fs.existsSync(envDevPath)) {
|
||||||
|
require('dotenv').config({ path: envDevPath });
|
||||||
|
}
|
||||||
if (fs.existsSync(envPath)) {
|
if (fs.existsSync(envPath)) {
|
||||||
require('dotenv').config({ path: envPath });
|
require('dotenv').config({ path: envPath });
|
||||||
}
|
}
|
||||||
@@ -51,6 +55,22 @@ function createDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropDatabase() {
|
||||||
|
try {
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
const dbName = dbUrl.split('/').pop();
|
||||||
|
|
||||||
|
// Drop existing connections and database
|
||||||
|
execSync(`psql "${dbUrl}" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${dbName}' AND pid <> pg_backend_pid();" 2>/dev/null || true`, { stdio: 'pipe' });
|
||||||
|
execSync(`psql "${dbUrl}" -c "DROP DATABASE IF EXISTS ${dbName};" 2>/dev/null || true`, { stdio: 'pipe' });
|
||||||
|
console.log(`✅ Database "${dbName}" dropped`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to drop database:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runMigrations() {
|
function runMigrations() {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Running migrations...');
|
console.log('🔄 Running migrations...');
|
||||||
@@ -76,6 +96,9 @@ function generatePrismaClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const shouldDrop = args.includes('--drop');
|
||||||
|
|
||||||
console.log('=== PostgreSQL Setup for EuchreCamp ===\n');
|
console.log('=== PostgreSQL Setup for EuchreCamp ===\n');
|
||||||
|
|
||||||
// Check if DATABASE_URL is set
|
// Check if DATABASE_URL is set
|
||||||
@@ -90,6 +113,14 @@ function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop database if requested
|
||||||
|
if (shouldDrop) {
|
||||||
|
console.log('Dropping existing database...');
|
||||||
|
if (!dropDatabase()) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create database
|
// Create database
|
||||||
if (!createDatabase()) {
|
if (!createDatabase()) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to development database
|
||||||
|
* Creates a .env.development.local file with development database settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const envDevPath = path.join(__dirname, '..', '.env.development');
|
||||||
|
const envDevLocalPath = path.join(__dirname, '..', '.env.development.local');
|
||||||
|
|
||||||
|
console.log('🔧 Setting up development database...\n');
|
||||||
|
|
||||||
|
// Check if .env.development exists
|
||||||
|
if (!fs.existsSync(envDevPath)) {
|
||||||
|
console.error('❌ .env.development file not found');
|
||||||
|
console.error('Please create it first or run: npm run db:setup-dev');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy .env.development to .env.development.local if it doesn't exist
|
||||||
|
if (!fs.existsSync(envDevLocalPath)) {
|
||||||
|
fs.copyFileSync(envDevPath, envDevLocalPath);
|
||||||
|
console.log('✅ Created .env.development.local');
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ .env.development.local already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set NODE_ENV for the current session
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
process.env.DATABASE_PROVIDER = 'postgresql';
|
||||||
|
|
||||||
|
// Read the development database URL
|
||||||
|
const envContent = fs.readFileSync(envDevPath, 'utf8');
|
||||||
|
const match = envContent.match(/DATABASE_URL="([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
process.env.DATABASE_URL = match[1];
|
||||||
|
console.log(`✅ Development database URL: ${process.env.DATABASE_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Development database configured!');
|
||||||
|
console.log('\nNext steps:');
|
||||||
|
console.log('1. Setup the dev database: npm run db:setup-dev');
|
||||||
|
console.log('2. Start development server: npm run dev');
|
||||||
|
console.log('\nNote: This script sets environment variables for the current session.');
|
||||||
|
console.log('For persistent configuration, use .env.development.local');
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, MockedFunction } from 'vitest'
|
import { describe, it, expect, mock, beforeEach } from 'bun:test'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import EditTournamentForm from '@/components/EditTournamentForm'
|
import EditTournamentForm from '@/components/EditTournamentForm'
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
vi.mock('next/navigation', () => ({
|
mock.module('next/navigation', () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: mock(() => {}),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock next/link
|
// Mock next/link
|
||||||
vi.mock('next/link', () => ({
|
mock.module('next/link', () => ({
|
||||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||||
<a href={href}>{children}</a>
|
<a href={href}>{children}</a>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
const mockFetch = vi.fn()
|
const mockFetch = mock(async () => new Response())
|
||||||
global.fetch = mockFetch as MockedFunction<typeof global.fetch>
|
global.fetch = mockFetch as any
|
||||||
|
|
||||||
const mockTournament = {
|
const mockTournament = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -40,7 +40,8 @@ const mockTournament = {
|
|||||||
|
|
||||||
describe('EditTournamentForm', () => {
|
describe('EditTournamentForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
// Only clear the specific mocks we create, not global module mocks
|
||||||
|
mockFetch.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders form with initial values', () => {
|
it('renders form with initial values', () => {
|
||||||
|
|||||||
@@ -5,31 +5,40 @@
|
|||||||
* Tests the navigation component for proper display based on session state
|
* Tests the navigation component for proper display based on session state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import Navigation from '@/components/Navigation'
|
import Navigation from '@/components/Navigation'
|
||||||
|
|
||||||
|
// Mock next/link
|
||||||
|
mock.module('next/link', () => ({
|
||||||
|
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||||
|
<a href={href}>{children}</a>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock the SessionProvider
|
// Mock the SessionProvider
|
||||||
vi.mock('@/components/SessionProvider', () => ({
|
mock.module('@/components/SessionProvider', () => ({
|
||||||
useSession: vi.fn(),
|
useSession: mock(() => ({ session: null, loading: false, refreshSession: mock(() => {}) })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock the auth-client
|
// Mock the auth-client
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
mock.module('@/lib/auth-client', () => ({
|
||||||
authClient: {
|
authClient: {
|
||||||
signOut: vi.fn(),
|
signOut: mock(() => {}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock fetch for role API call
|
// Mock fetch for role API call
|
||||||
global.fetch = vi.fn()
|
global.fetch = mock(async () => new Response()) as any
|
||||||
|
|
||||||
import { useSession } from '@/components/SessionProvider'
|
import { useSession as useSessionOriginal } from '@/components/SessionProvider'
|
||||||
|
const useSession = useSessionOriginal as any
|
||||||
|
|
||||||
describe('Epic 1: Navigation Component', () => {
|
describe('Epic 1: Navigation Component', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
// Don't clear all mocks as it might affect bun-setup.ts
|
||||||
vi.mocked(global.fetch).mockImplementation(async (url) => {
|
// Set up default fetch mock
|
||||||
|
(global.fetch as any).mockImplementation?.(async (url: any) => {
|
||||||
if (url?.toString().includes('/api/users/')) {
|
if (url?.toString().includes('/api/users/')) {
|
||||||
return new Response(JSON.stringify({ role: 'player' }), {
|
return new Response(JSON.stringify({ role: 'player' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -41,14 +50,15 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
// Don't clear mocks - let them persist for other test files
|
||||||
|
// Navigation relies on module mocks set up at file level
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the logo and basic links when not logged in', () => {
|
it('renders the logo and basic links when not logged in', () => {
|
||||||
vi.mocked(useSession).mockReturnValue({
|
(useSession).mockReturnValue({
|
||||||
session: null,
|
session: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshSession: vi.fn(),
|
refreshSession: mock(() => {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<Navigation />)
|
render(<Navigation />)
|
||||||
@@ -60,7 +70,7 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows user menu when logged in', async () => {
|
it('shows user menu when logged in', async () => {
|
||||||
vi.mocked(useSession).mockReturnValue({
|
(useSession).mockReturnValue({
|
||||||
session: {
|
session: {
|
||||||
user: {
|
user: {
|
||||||
id: 'user-123',
|
id: 'user-123',
|
||||||
@@ -71,7 +81,7 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
session: { token: 'abc123' },
|
session: { token: 'abc123' },
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshSession: vi.fn(),
|
refreshSession: mock(() => {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<Navigation />)
|
render(<Navigation />)
|
||||||
@@ -85,7 +95,7 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows Tournaments link when logged in', async () => {
|
it('shows Tournaments link when logged in', async () => {
|
||||||
vi.mocked(useSession).mockReturnValue({
|
(useSession).mockReturnValue({
|
||||||
session: {
|
session: {
|
||||||
user: {
|
user: {
|
||||||
id: 'user-123',
|
id: 'user-123',
|
||||||
@@ -96,7 +106,7 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
session: { token: 'abc123' },
|
session: { token: 'abc123' },
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshSession: vi.fn(),
|
refreshSession: mock(() => {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<Navigation />)
|
render(<Navigation />)
|
||||||
@@ -107,7 +117,7 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows admin link for club_admin role', async () => {
|
it('shows admin link for club_admin role', async () => {
|
||||||
vi.mocked(useSession).mockReturnValue({
|
(useSession).mockReturnValue({
|
||||||
session: {
|
session: {
|
||||||
user: {
|
user: {
|
||||||
id: 'admin-123',
|
id: 'admin-123',
|
||||||
@@ -118,11 +128,11 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
session: { token: 'abc123' },
|
session: { token: 'abc123' },
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshSession: vi.fn(),
|
refreshSession: mock(() => {}),
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mock fetch to return club_admin role
|
// Mock fetch to return club_admin role
|
||||||
vi.mocked(global.fetch).mockImplementation(async (url) => {
|
(global.fetch as any).mockImplementation(async (url: any) => {
|
||||||
if (url?.toString().includes('/api/users/admin-123/role')) {
|
if (url?.toString().includes('/api/users/admin-123/role')) {
|
||||||
return new Response(JSON.stringify({ role: 'club_admin' }), {
|
return new Response(JSON.stringify({ role: 'club_admin' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -141,7 +151,7 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('hides admin link for non-admin users', async () => {
|
it('hides admin link for non-admin users', async () => {
|
||||||
vi.mocked(useSession).mockReturnValue({
|
(useSession).mockReturnValue({
|
||||||
session: {
|
session: {
|
||||||
user: {
|
user: {
|
||||||
id: 'player-123',
|
id: 'player-123',
|
||||||
@@ -152,11 +162,11 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
session: { token: 'abc123' },
|
session: { token: 'abc123' },
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshSession: vi.fn(),
|
refreshSession: mock(() => {}),
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mock fetch to return player role (already set in beforeEach, but explicit here)
|
// Mock fetch to return player role (already set in beforeEach, but explicit here)
|
||||||
vi.mocked(global.fetch).mockImplementation(async (url) => {
|
(global.fetch as any).mockImplementation(async (url: any) => {
|
||||||
if (url?.toString().includes('/api/users/')) {
|
if (url?.toString().includes('/api/users/')) {
|
||||||
return {
|
return {
|
||||||
json: () => Promise.resolve({ role: 'player' }),
|
json: () => Promise.resolve({ role: 'player' }),
|
||||||
@@ -176,10 +186,10 @@ describe('Epic 1: Navigation Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows loading state', () => {
|
it('shows loading state', () => {
|
||||||
vi.mocked(useSession).mockReturnValue({
|
(useSession).mockReturnValue({
|
||||||
session: null,
|
session: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
refreshSession: vi.fn(),
|
refreshSession: mock(() => {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<Navigation />)
|
render(<Navigation />)
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, MockedFunction } from 'vitest'
|
import { describe, it, expect, mock, beforeEach } from 'bun:test'
|
||||||
import { getSession } from '@/lib/auth-simple'
|
import { getSession } from '@/lib/auth-simple'
|
||||||
|
|
||||||
// Mock next/headers
|
// Mock next/headers
|
||||||
vi.mock('next/headers', () => ({
|
mock.module('next/headers', () => ({
|
||||||
cookies: vi.fn().mockResolvedValue({
|
cookies: mock(() => Promise.resolve({
|
||||||
get: vi.fn().mockReturnValue({ name: 'better-auth.session_token', value: 'test-token' }),
|
get: mock(() => ({ name: 'better-auth.session_token', value: 'test-token' })),
|
||||||
}),
|
})),
|
||||||
headers: vi.fn().mockResolvedValue({
|
headers: mock(() => Promise.resolve({
|
||||||
get: vi.fn().mockReturnValue(null),
|
get: mock(() => null),
|
||||||
}),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
const mockFetch = vi.fn()
|
const mockFetch = mock(async () => new Response())
|
||||||
global.fetch = mockFetch as MockedFunction<typeof global.fetch>
|
global.fetch = mockFetch as any
|
||||||
|
|
||||||
describe('getSession', () => {
|
describe('getSession', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
mock.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns session data when auth API returns 200', async () => {
|
it('returns session data when auth API returns 200', async () => {
|
||||||
@@ -26,7 +26,7 @@ describe('getSession', () => {
|
|||||||
user: { id: 'user-123', email: 'test@example.com' },
|
user: { id: 'user-123', email: 'test@example.com' },
|
||||||
}
|
}
|
||||||
|
|
||||||
mockFetch.mockResolvedValue(
|
mockFetch.mockImplementation(async () =>
|
||||||
new Response(JSON.stringify(mockSession), { status: 200 })
|
new Response(JSON.stringify(mockSession), { status: 200 })
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ describe('getSession', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when auth API returns non-200 status', async () => {
|
it('returns null when auth API returns non-200 status', async () => {
|
||||||
mockFetch.mockResolvedValue(
|
mockFetch.mockImplementation(async () =>
|
||||||
new Response(null, { status: 401 })
|
new Response(null, { status: 401 })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Setup file for Bun test runner to provide DOM environment
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { mock } from 'bun:test';
|
||||||
|
|
||||||
|
console.log('Loading bun-setup.ts...');
|
||||||
|
|
||||||
|
// Mock @prisma/client to avoid dependency on generated Prisma client
|
||||||
|
// This allows unit tests to run without requiring `prisma generate`
|
||||||
|
mock.module('@prisma/client', () => {
|
||||||
|
return {
|
||||||
|
PrismaClient: class MockPrismaClient {
|
||||||
|
$connect() {}
|
||||||
|
$disconnect() {}
|
||||||
|
$transaction() {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||||
|
url: 'http://localhost',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
(global as any).window = dom.window;
|
||||||
|
(global as any).document = dom.window.document;
|
||||||
|
(global as any).navigator = dom.window.navigator;
|
||||||
|
|
||||||
|
console.log('bun-setup.ts loaded - document:', typeof (global as any).document);
|
||||||
|
console.log('document.body:', (global as any).document?.body);
|
||||||
|
|
||||||
|
// Import jest-dom matchers after setting up the DOM
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Clear document body after each test
|
||||||
|
import { afterEach } from 'bun:test';
|
||||||
|
afterEach(() => {
|
||||||
|
if (global.document?.body) {
|
||||||
|
global.document.body.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="@testing-library/jest-dom" />
|
||||||
|
|
||||||
|
import { type expect } from 'bun:test'
|
||||||
|
import { type TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
|
||||||
|
|
||||||
|
declare module 'bun:test' {
|
||||||
|
interface Matchers<T = any>
|
||||||
|
extends TestingLibraryMatchers<
|
||||||
|
ReturnType<typeof expect.stringContaining>,
|
||||||
|
T
|
||||||
|
> {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Test Utilities for EuchreCamp
|
||||||
|
*
|
||||||
|
* Provides helper functions for creating and cleaning up test data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import type { User, Player, Event, Match } from '@prisma/client';
|
||||||
|
|
||||||
|
// Track created test records for cleanup
|
||||||
|
const createdRecords = {
|
||||||
|
users: [] as string[],
|
||||||
|
players: [] as number[],
|
||||||
|
events: [] as number[],
|
||||||
|
matches: [] as number[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test user with optional player association
|
||||||
|
*/
|
||||||
|
export async function createTestUser(options: {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
role?: string;
|
||||||
|
playerId?: number | null;
|
||||||
|
} = {}) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const email = options.email || `test-user-${timestamp}@example.com`;
|
||||||
|
const name = options.name || `Test User ${timestamp}`;
|
||||||
|
const role = options.role || 'player';
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
playerId: options.playerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdRecords.users.push(user.id);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test player
|
||||||
|
*/
|
||||||
|
export async function createTestPlayer(options: {
|
||||||
|
name?: string;
|
||||||
|
currentElo?: number;
|
||||||
|
} = {}) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const name = options.name || `Test Player ${timestamp}`;
|
||||||
|
|
||||||
|
const player = await prisma.player.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
normalizedName: name.toLowerCase(),
|
||||||
|
currentElo: options.currentElo || 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdRecords.players.push(player.id);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test event (tournament)
|
||||||
|
*/
|
||||||
|
export async function createTestEvent(options: {
|
||||||
|
name?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
} = {}) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const name = options.name || `Test Event ${timestamp}`;
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
ownerId: options.ownerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdRecords.events.push(event.id);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test match
|
||||||
|
*/
|
||||||
|
export async function createTestMatch(options: {
|
||||||
|
eventId?: number;
|
||||||
|
team1P1Id: number;
|
||||||
|
team1P2Id: number;
|
||||||
|
team2P1Id: number;
|
||||||
|
team2P2Id: number;
|
||||||
|
team1Score?: number;
|
||||||
|
team2Score?: number;
|
||||||
|
}) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const match = await prisma.match.create({
|
||||||
|
data: {
|
||||||
|
eventId: options.eventId,
|
||||||
|
team1P1Id: options.team1P1Id,
|
||||||
|
team1P2Id: options.team1P2Id,
|
||||||
|
team2P1Id: options.team2P1Id,
|
||||||
|
team2P2Id: options.team2P2Id,
|
||||||
|
team1Score: options.team1Score ?? 10,
|
||||||
|
team2Score: options.team2Score ?? 5,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdRecords.matches.push(match.id);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all created test records
|
||||||
|
*/
|
||||||
|
export async function cleanupTestRecords() {
|
||||||
|
console.log('🧹 Cleaning up test records...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete matches first (they reference players)
|
||||||
|
if (createdRecords.matches.length > 0) {
|
||||||
|
await prisma.match.deleteMany({
|
||||||
|
where: { id: { in: createdRecords.matches } },
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${createdRecords.matches.length} matches`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete events
|
||||||
|
if (createdRecords.events.length > 0) {
|
||||||
|
await prisma.event.deleteMany({
|
||||||
|
where: { id: { in: createdRecords.events } },
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${createdRecords.events.length} events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete users first (to break player associations)
|
||||||
|
if (createdRecords.users.length > 0) {
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { id: { in: createdRecords.users } },
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${createdRecords.users.length} users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete players
|
||||||
|
if (createdRecords.players.length > 0) {
|
||||||
|
await prisma.player.deleteMany({
|
||||||
|
where: { id: { in: createdRecords.players } },
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${createdRecords.players.length} players`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tracking
|
||||||
|
createdRecords.users = [];
|
||||||
|
createdRecords.players = [];
|
||||||
|
createdRecords.events = [];
|
||||||
|
createdRecords.matches = [];
|
||||||
|
|
||||||
|
console.log('✅ Test records cleaned up successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error cleaning up test records:', error);
|
||||||
|
// Still reset tracking to avoid trying to delete again
|
||||||
|
createdRecords.users = [];
|
||||||
|
createdRecords.players = [];
|
||||||
|
createdRecords.events = [];
|
||||||
|
createdRecords.matches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of created records (for assertions)
|
||||||
|
*/
|
||||||
|
export function getCreatedRecordCounts() {
|
||||||
|
return {
|
||||||
|
users: createdRecords.users.length,
|
||||||
|
players: createdRecords.players.length,
|
||||||
|
events: createdRecords.events.length,
|
||||||
|
matches: createdRecords.matches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up test users by email pattern
|
||||||
|
*/
|
||||||
|
export async function cleanupTestUsersByEmailPattern(pattern: string = 'test-') {
|
||||||
|
try {
|
||||||
|
const result = await prisma.user.deleteMany({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
contains: pattern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${result.count} test users matching pattern "${pattern}"`);
|
||||||
|
return result.count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error cleaning up test users:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up test players by name pattern
|
||||||
|
*/
|
||||||
|
export async function cleanupTestPlayersByNamePattern(pattern: string = 'Test ') {
|
||||||
|
try {
|
||||||
|
const result = await prisma.player.deleteMany({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
contains: pattern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${result.count} test players matching pattern "${pattern}"`);
|
||||||
|
return result.count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error cleaning up test players:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all test data (aggressive cleanup)
|
||||||
|
*/
|
||||||
|
export async function cleanupAllTestData() {
|
||||||
|
console.log('🧹 Aggressive cleanup of all test data...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete test users first
|
||||||
|
const userResult = await prisma.user.deleteMany({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
contains: 'test-',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${userResult.count} test users`);
|
||||||
|
|
||||||
|
// Delete test players
|
||||||
|
const playerResult = await prisma.player.deleteMany({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
contains: 'Test ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${playerResult.count} test players`);
|
||||||
|
|
||||||
|
// Delete test events
|
||||||
|
const eventResult = await prisma.event.deleteMany({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
contains: 'Test ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Deleted ${eventResult.count} test events`);
|
||||||
|
|
||||||
|
console.log('✅ All test data cleaned up');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error during aggressive cleanup:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export cleanup function for global setup/teardown
|
||||||
|
export const testCleanup = cleanupTestRecords;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Tests the mathematical correctness of Elo calculations and rating updates
|
* Tests the mathematical correctness of Elo calculations and rating updates
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import { calculateEloChange, calculateExpectedScore, calculateTeamElo } from '@/lib/elo-utils';
|
import { calculateEloChange, calculateExpectedScore, calculateTeamElo } from '@/lib/elo-utils';
|
||||||
|
|
||||||
describe('Elo Rating System', () => {
|
describe('Elo Rating System', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Tests for ID parsing and validation in API routes and pages
|
* Tests for ID parsing and validation in API routes and pages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
|
||||||
describe('ID Validation', () => {
|
describe('ID Validation', () => {
|
||||||
describe('parseInt with validation', () => {
|
describe('parseInt with validation', () => {
|
||||||
|
|||||||
@@ -4,12 +4,33 @@
|
|||||||
* Tests the permission system for tournament management
|
* Tests the permission system for tournament management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, vi } from 'vitest';
|
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
||||||
import { hasRole, canManageTournament, canCreateTournaments } from '@/lib/permissions';
|
import { hasRole, canManageTournament, canCreateTournaments } from '@/lib/permissions';
|
||||||
import { getSession } from '@/lib/auth-simple';
|
import { getSession } from '@/lib/auth-simple';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
|
// Create mock functions at module level
|
||||||
|
const getSessionMock = mock(() => {});
|
||||||
|
const userFindUniqueMock = mock(() => {});
|
||||||
|
const eventFindUniqueMock = mock(() => {});
|
||||||
|
|
||||||
|
// Mock the getSession and prisma functions
|
||||||
|
mock.module('@/lib/auth-simple', () => ({
|
||||||
|
getSession: getSessionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
user: {
|
||||||
|
findUnique: userFindUniqueMock,
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
findUnique: eventFindUniqueMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Helper to create mock user
|
// Helper to create mock user
|
||||||
const createMockUser = (id: string, email: string, role: string): User => ({
|
const createMockUser = (id: string, email: string, role: string): User => ({
|
||||||
id,
|
id,
|
||||||
@@ -23,30 +44,21 @@ const createMockUser = (id: string, email: string, role: string): User => ({
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the getSession and prisma functions
|
|
||||||
vi.mock('@/lib/auth-simple', () => ({
|
|
||||||
getSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
event: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Permissions', () => {
|
describe('Permissions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mock implementations to default (no-op) before each test
|
||||||
|
getSessionMock.mockImplementation(() => undefined);
|
||||||
|
userFindUniqueMock.mockImplementation(() => undefined);
|
||||||
|
eventFindUniqueMock.mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
describe('hasRole', () => {
|
describe('hasRole', () => {
|
||||||
test('should allow club_admin to access tournament_admin resources', async () => {
|
test('should allow club_admin to access tournament_admin resources', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: '1', email: 'test@example.com' },
|
user: { id: '1', email: 'test@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('1', 'test@example.com', 'club_admin')
|
createMockUser('1', 'test@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,11 +67,11 @@ describe('Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny player from accessing tournament_admin resources', async () => {
|
test('should deny player from accessing tournament_admin resources', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: '1', email: 'test@example.com' },
|
user: { id: '1', email: 'test@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('1', 'test@example.com', 'player')
|
createMockUser('1', 'test@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +80,7 @@ describe('Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny unauthenticated user', async () => {
|
test('should deny unauthenticated user', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(null);
|
getSessionMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const result = await hasRole('club_admin');
|
const result = await hasRole('club_admin');
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
@@ -78,11 +90,11 @@ describe('Permissions', () => {
|
|||||||
|
|
||||||
describe('canManageTournament', () => {
|
describe('canManageTournament', () => {
|
||||||
test('should allow club_admin to manage any tournament', async () => {
|
test('should allow club_admin to manage any tournament', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,11 +103,11 @@ describe('Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny player from managing tournaments', async () => {
|
test('should deny player from managing tournaments', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'player-1', email: 'player@example.com' },
|
user: { id: 'player-1', email: 'player@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('player-1', 'player@example.com', 'player')
|
createMockUser('player-1', 'player@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,11 +119,11 @@ describe('Permissions', () => {
|
|||||||
|
|
||||||
describe('canCreateTournaments', () => {
|
describe('canCreateTournaments', () => {
|
||||||
test('should allow tournament_admin to create tournaments', async () => {
|
test('should allow tournament_admin to create tournaments', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('admin-1', 'admin@example.com', 'tournament_admin')
|
createMockUser('admin-1', 'admin@example.com', 'tournament_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,11 +132,11 @@ describe('Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should allow club_admin to create tournaments', async () => {
|
test('should allow club_admin to create tournaments', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -133,11 +145,11 @@ describe('Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny player from creating tournaments', async () => {
|
test('should deny player from creating tournaments', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'player-1', email: 'player@example.com' },
|
user: { id: 'player-1', email: 'player@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('player-1', 'player@example.com', 'player')
|
createMockUser('player-1', 'player@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,19 @@
|
|||||||
* Tests the player deduplication logic for CSV uploads
|
* Tests the player deduplication logic for CSV uploads
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
import { describe, test, expect, mock, beforeEach,} from 'bun:test';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// Create mock functions at module level
|
||||||
|
const playerFindFirstMock = mock(() => {});
|
||||||
|
const playerCreateMock = mock(() => {});
|
||||||
|
|
||||||
// Mock the prisma module
|
// Mock the prisma module
|
||||||
vi.mock('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
player: {
|
player: {
|
||||||
findFirst: vi.fn(),
|
findFirst: playerFindFirstMock,
|
||||||
create: vi.fn(),
|
create: playerCreateMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -46,7 +50,9 @@ async function findOrCreatePlayer(name: string) {
|
|||||||
|
|
||||||
describe('Player Deduplication', () => {
|
describe('Player Deduplication', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
// Clear all mock history before each test
|
||||||
|
playerFindFirstMock.mockClear();
|
||||||
|
playerCreateMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findOrCreatePlayer', () => {
|
describe('findOrCreatePlayer', () => {
|
||||||
@@ -64,7 +70,7 @@ describe('Player Deduplication', () => {
|
|||||||
rating: 0,
|
rating: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
|
playerFindFirstMock.mockImplementation(async () => mockPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer('Emily');
|
const result = await findOrCreatePlayer('Emily');
|
||||||
|
|
||||||
@@ -89,7 +95,7 @@ describe('Player Deduplication', () => {
|
|||||||
rating: 0,
|
rating: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
|
playerFindFirstMock.mockImplementation(async () => mockPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer('EMILY');
|
const result = await findOrCreatePlayer('EMILY');
|
||||||
|
|
||||||
@@ -114,7 +120,7 @@ describe('Player Deduplication', () => {
|
|||||||
rating: 0,
|
rating: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
|
playerFindFirstMock.mockImplementation(async () => mockPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer(' Emily ');
|
const result = await findOrCreatePlayer(' Emily ');
|
||||||
|
|
||||||
@@ -126,7 +132,7 @@ describe('Player Deduplication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should create new player if not found', async () => {
|
test('should create new player if not found', async () => {
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
|
playerFindFirstMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const newPlayer = {
|
const newPlayer = {
|
||||||
id: 100,
|
id: 100,
|
||||||
@@ -141,7 +147,7 @@ describe('Player Deduplication', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
|
playerCreateMock.mockImplementation(async () => newPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer('NewPlayer');
|
const result = await findOrCreatePlayer('NewPlayer');
|
||||||
|
|
||||||
@@ -162,7 +168,7 @@ describe('Player Deduplication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle names with special characters', async () => {
|
test('should handle names with special characters', async () => {
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
|
playerFindFirstMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const newPlayer = {
|
const newPlayer = {
|
||||||
id: 100,
|
id: 100,
|
||||||
@@ -177,7 +183,7 @@ describe('Player Deduplication', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
|
playerCreateMock.mockImplementation(async () => newPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer('Test-Player_123');
|
const result = await findOrCreatePlayer('Test-Player_123');
|
||||||
|
|
||||||
@@ -195,7 +201,7 @@ describe('Player Deduplication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle names with spaces', async () => {
|
test('should handle names with spaces', async () => {
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
|
playerFindFirstMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const newPlayer = {
|
const newPlayer = {
|
||||||
id: 100,
|
id: 100,
|
||||||
@@ -210,7 +216,7 @@ describe('Player Deduplication', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
|
playerCreateMock.mockImplementation(async () => newPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer('Dave B');
|
const result = await findOrCreatePlayer('Dave B');
|
||||||
|
|
||||||
@@ -242,7 +248,7 @@ describe('Player Deduplication', () => {
|
|||||||
rating: 0,
|
rating: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
|
playerFindFirstMock.mockImplementation(async () => mockPlayer);
|
||||||
|
|
||||||
const result1 = await findOrCreatePlayer('EMILY');
|
const result1 = await findOrCreatePlayer('EMILY');
|
||||||
const result2 = await findOrCreatePlayer('Emily');
|
const result2 = await findOrCreatePlayer('Emily');
|
||||||
@@ -255,7 +261,7 @@ describe('Player Deduplication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle empty or whitespace-only names', async () => {
|
test('should handle empty or whitespace-only names', async () => {
|
||||||
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
|
playerFindFirstMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const newPlayer = {
|
const newPlayer = {
|
||||||
id: 100,
|
id: 100,
|
||||||
@@ -270,7 +276,7 @@ describe('Player Deduplication', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
|
playerCreateMock.mockImplementation(async () => newPlayer);
|
||||||
|
|
||||||
const result = await findOrCreatePlayer(' ');
|
const result = await findOrCreatePlayer(' ');
|
||||||
|
|
||||||
@@ -320,7 +326,7 @@ describe('Player Deduplication', () => {
|
|||||||
rating: 0,
|
rating: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.player.findFirst)
|
playerFindFirstMock
|
||||||
.mockResolvedValueOnce(existingPlayer) // First call for "Emily"
|
.mockResolvedValueOnce(existingPlayer) // First call for "Emily"
|
||||||
.mockResolvedValueOnce(existingPlayer) // Second call for "EMILY"
|
.mockResolvedValueOnce(existingPlayer) // Second call for "EMILY"
|
||||||
.mockResolvedValueOnce(existingPlayer); // Third call for " Emily "
|
.mockResolvedValueOnce(existingPlayer); // Third call for " Emily "
|
||||||
|
|||||||
@@ -7,24 +7,30 @@
|
|||||||
* - Partnership performance
|
* - Partnership performance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import type { Player, Event, Match, PartnershipStat } from '@prisma/client';
|
import type { Player, Event, Match, PartnershipStat } from '@prisma/client';
|
||||||
|
|
||||||
|
// Create mock functions at module level
|
||||||
|
const playerFindUniqueMock = mock(() => {});
|
||||||
|
const eventFindManyMock = mock(() => {});
|
||||||
|
const matchFindManyMock = mock(() => {});
|
||||||
|
const partnershipStatFindManyMock = mock(() => {});
|
||||||
|
|
||||||
// Mock the prisma module
|
// Mock the prisma module
|
||||||
vi.mock('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
player: {
|
player: {
|
||||||
findUnique: vi.fn(),
|
findUnique: playerFindUniqueMock,
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
findMany: vi.fn(),
|
findMany: eventFindManyMock,
|
||||||
},
|
},
|
||||||
match: {
|
match: {
|
||||||
findMany: vi.fn(),
|
findMany: matchFindManyMock,
|
||||||
},
|
},
|
||||||
partnershipStat: {
|
partnershipStat: {
|
||||||
findMany: vi.fn(),
|
findMany: partnershipStatFindManyMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -111,7 +117,11 @@ const createMockPartnershipStat = (
|
|||||||
|
|
||||||
describe('Player Profile Enhancements', () => {
|
describe('Player Profile Enhancements', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
// Reset mock implementations to default (no-op) before each test
|
||||||
|
playerFindUniqueMock.mockImplementation(() => undefined);
|
||||||
|
eventFindManyMock.mockImplementation(() => undefined);
|
||||||
|
matchFindManyMock.mockImplementation(() => undefined);
|
||||||
|
partnershipStatFindManyMock.mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Tournaments Participated', () => {
|
describe('Tournaments Participated', () => {
|
||||||
@@ -122,8 +132,8 @@ describe('Player Profile Enhancements', () => {
|
|||||||
createMockTournament(2, 'Tournament B'),
|
createMockTournament(2, 'Tournament B'),
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
|
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
|
||||||
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
|
eventFindManyMock.mockImplementation(async () => mockTournaments);
|
||||||
|
|
||||||
// Simulate the query that would be run
|
// Simulate the query that would be run
|
||||||
const tournaments = await prisma.event.findMany({
|
const tournaments = await prisma.event.findMany({
|
||||||
@@ -145,8 +155,8 @@ describe('Player Profile Enhancements', () => {
|
|||||||
test('should return empty array if player has no tournaments', async () => {
|
test('should return empty array if player has no tournaments', async () => {
|
||||||
const mockPlayer = createMockPlayer(1, 'Test Player');
|
const mockPlayer = createMockPlayer(1, 'Test Player');
|
||||||
|
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
|
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
|
||||||
vi.mocked(prisma.event.findMany).mockResolvedValue([]);
|
eventFindManyMock.mockImplementation(async () => []);
|
||||||
|
|
||||||
const tournaments = await prisma.event.findMany({
|
const tournaments = await prisma.event.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -173,8 +183,8 @@ describe('Player Profile Enhancements', () => {
|
|||||||
createMockMatch(2, 1, 3, 5, 6, 4, 4),
|
createMockMatch(2, 1, 3, 5, 6, 4, 4),
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
|
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
|
||||||
vi.mocked(prisma.match.findMany).mockResolvedValue(mockMatches);
|
matchFindManyMock.mockImplementation(async () => mockMatches);
|
||||||
|
|
||||||
// Simulate the query that would be run
|
// Simulate the query that would be run
|
||||||
const matches = await prisma.match.findMany({
|
const matches = await prisma.match.findMany({
|
||||||
@@ -204,8 +214,8 @@ describe('Player Profile Enhancements', () => {
|
|||||||
test('should return empty array if player has no matches', async () => {
|
test('should return empty array if player has no matches', async () => {
|
||||||
const mockPlayer = createMockPlayer(1, 'Test Player');
|
const mockPlayer = createMockPlayer(1, 'Test Player');
|
||||||
|
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
|
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
|
||||||
vi.mocked(prisma.match.findMany).mockResolvedValue([]);
|
matchFindManyMock.mockImplementation(async () => []);
|
||||||
|
|
||||||
const matches = await prisma.match.findMany({
|
const matches = await prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -240,8 +250,8 @@ describe('Player Profile Enhancements', () => {
|
|||||||
createMockPartnershipStat(2, 1, 3, 5, 2, 3),
|
createMockPartnershipStat(2, 1, 3, 5, 2, 3),
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
|
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
|
||||||
vi.mocked(prisma.partnershipStat.findMany).mockResolvedValue(mockPartnershipStats);
|
partnershipStatFindManyMock.mockImplementation(async () => mockPartnershipStats);
|
||||||
|
|
||||||
// Simulate the query that would be run
|
// Simulate the query that would be run
|
||||||
const partnershipStats = await prisma.partnershipStat.findMany({
|
const partnershipStats = await prisma.partnershipStat.findMany({
|
||||||
@@ -265,8 +275,8 @@ describe('Player Profile Enhancements', () => {
|
|||||||
test('should return empty array if player has no partnership data', async () => {
|
test('should return empty array if player has no partnership data', async () => {
|
||||||
const mockPlayer = createMockPlayer(1, 'Test Player');
|
const mockPlayer = createMockPlayer(1, 'Test Player');
|
||||||
|
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
|
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
|
||||||
vi.mocked(prisma.partnershipStat.findMany).mockResolvedValue([]);
|
partnershipStatFindManyMock.mockImplementation(async () => []);
|
||||||
|
|
||||||
const partnershipStats = await prisma.partnershipStat.findMany({
|
const partnershipStats = await prisma.partnershipStat.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -5,33 +5,33 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||||
import { recalculateAllElo } from '@/lib/elo-utils';
|
import { recalculateAllElo } from '@/lib/elo-utils';
|
||||||
|
|
||||||
// Mock Prisma client
|
// Mock Prisma client
|
||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
player: {
|
player: {
|
||||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
updateMany: mock(() => {}).mockResolvedValue({ count: 0 }),
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: mock(() => {}).mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
eloSnapshot: {
|
eloSnapshot: {
|
||||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
deleteMany: mock(() => {}).mockResolvedValue({ count: 0 }),
|
||||||
create: vi.fn().mockResolvedValue({}),
|
create: mock(() => {}).mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
partnershipStat: {
|
partnershipStat: {
|
||||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
deleteMany: mock(() => {}).mockResolvedValue({ count: 0 }),
|
||||||
findFirst: vi.fn().mockResolvedValue(null), // No existing stats initially
|
findFirst: mock(() => {}).mockResolvedValue(null), // No existing stats initially
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: mock(() => {}).mockResolvedValue({}),
|
||||||
create: vi.fn().mockResolvedValue({}),
|
create: mock(() => {}).mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
match: {
|
match: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: mock(() => {}).mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('recalculateAllElo', () => {
|
describe('recalculateAllElo', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
mock.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reset all player stats to zero', async () => {
|
test('should reset all player stats to zero', async () => {
|
||||||
|
|||||||
@@ -5,25 +5,31 @@
|
|||||||
* Regression tests for the issue where tournament_admin users were redirected to login
|
* Regression tests for the issue where tournament_admin users were redirected to login
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
||||||
import { canManageTournament, ownsTournament, getManageableTournaments } from '@/lib/permissions';
|
import { canManageTournament, ownsTournament, getManageableTournaments } from '@/lib/permissions';
|
||||||
import { getSession } from '@/lib/auth-simple';
|
import { getSession } from '@/lib/auth-simple';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import type { User, Event } from '@prisma/client';
|
import type { User, Event } from '@prisma/client';
|
||||||
|
|
||||||
|
// Create mock functions first
|
||||||
|
const getSessionMock = mock(() => {});
|
||||||
|
const userFindUniqueMock = mock(() => {});
|
||||||
|
const eventFindUniqueMock = mock(() => {});
|
||||||
|
const eventFindManyMock = mock(() => {});
|
||||||
|
|
||||||
// Mock the getSession and prisma functions
|
// Mock the getSession and prisma functions
|
||||||
vi.mock('@/lib/auth-simple', () => ({
|
mock.module('@/lib/auth-simple', () => ({
|
||||||
getSession: vi.fn(),
|
getSession: getSessionMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn(),
|
findUnique: userFindUniqueMock,
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
findUnique: vi.fn(),
|
findUnique: eventFindUniqueMock,
|
||||||
findMany: vi.fn(),
|
findMany: eventFindManyMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -61,16 +67,21 @@ const createMockTournament = (id: number, ownerId: string | null): Event => ({
|
|||||||
|
|
||||||
describe('Tournament Permissions', () => {
|
describe('Tournament Permissions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
// Reset mock implementations to default (no-op) before each test
|
||||||
|
// This prevents pollution from previous tests
|
||||||
|
getSessionMock.mockImplementation(() => undefined);
|
||||||
|
userFindUniqueMock.mockImplementation(() => undefined);
|
||||||
|
eventFindUniqueMock.mockImplementation(() => undefined);
|
||||||
|
eventFindManyMock.mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('canManageTournament', () => {
|
describe('canManageTournament', () => {
|
||||||
test('should allow club_admin to manage any tournament', async () => {
|
test('should allow club_admin to manage any tournament', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,14 +90,14 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should allow tournament_admin to manage their own tournament', async () => {
|
test('should allow tournament_admin to manage their own tournament', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
||||||
);
|
);
|
||||||
vi.mocked(prisma.event.findUnique).mockResolvedValue(
|
eventFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockTournament(1, 'tour-admin-1')
|
createMockTournament(1, 'tour-admin-1')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,14 +106,14 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny tournament_admin from managing other users tournaments', async () => {
|
test('should deny tournament_admin from managing other users tournaments', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
||||||
);
|
);
|
||||||
vi.mocked(prisma.event.findUnique).mockResolvedValue(
|
eventFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockTournament(1, 'other-user-1')
|
createMockTournament(1, 'other-user-1')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,11 +123,11 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny player from managing tournaments', async () => {
|
test('should deny player from managing tournaments', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'player-1', email: 'player@example.com' },
|
user: { id: 'player-1', email: 'player@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('player-1', 'player@example.com', 'player')
|
createMockUser('player-1', 'player@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,7 +137,7 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should deny unauthenticated user', async () => {
|
test('should deny unauthenticated user', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(null);
|
getSessionMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const result = await canManageTournament(999);
|
const result = await canManageTournament(999);
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
@@ -136,11 +147,11 @@ describe('Tournament Permissions', () => {
|
|||||||
|
|
||||||
describe('ownsTournament', () => {
|
describe('ownsTournament', () => {
|
||||||
test('should return true if user owns tournament', async () => {
|
test('should return true if user owns tournament', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'owner-1', email: 'owner@example.com' },
|
user: { id: 'owner-1', email: 'owner@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.event.findUnique).mockResolvedValue(
|
eventFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockTournament(1, 'owner-1')
|
createMockTournament(1, 'owner-1')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,11 +160,11 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should return false if user does not own tournament', async () => {
|
test('should return false if user does not own tournament', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'non-owner-1', email: 'nonowner@example.com' },
|
user: { id: 'non-owner-1', email: 'nonowner@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.event.findUnique).mockResolvedValue(
|
eventFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockTournament(1, 'owner-1')
|
createMockTournament(1, 'owner-1')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,11 +176,11 @@ describe('Tournament Permissions', () => {
|
|||||||
|
|
||||||
describe('getManageableTournaments', () => {
|
describe('getManageableTournaments', () => {
|
||||||
test('should return all tournaments for club_admin', async () => {
|
test('should return all tournaments for club_admin', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -178,11 +189,11 @@ describe('Tournament Permissions', () => {
|
|||||||
createMockTournament(2, 'user-2'),
|
createMockTournament(2, 'user-2'),
|
||||||
createMockTournament(3, 'user-3'),
|
createMockTournament(3, 'user-3'),
|
||||||
];
|
];
|
||||||
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
|
eventFindManyMock.mockImplementation(async () => mockTournaments);
|
||||||
|
|
||||||
const result = await getManageableTournaments();
|
const result = await getManageableTournaments();
|
||||||
expect(result).toEqual(mockTournaments);
|
expect(result).toEqual(mockTournaments);
|
||||||
expect(prisma.event.findMany).toHaveBeenCalledWith({
|
expect(eventFindManyMock).toHaveBeenCalledWith({
|
||||||
where: { eventType: 'tournament' },
|
where: { eventType: 'tournament' },
|
||||||
include: { participants: true },
|
include: { participants: true },
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
@@ -190,11 +201,11 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should return only owned tournaments for tournament_admin', async () => {
|
test('should return only owned tournaments for tournament_admin', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,11 +213,11 @@ describe('Tournament Permissions', () => {
|
|||||||
createMockTournament(1, 'tour-admin-1'),
|
createMockTournament(1, 'tour-admin-1'),
|
||||||
createMockTournament(2, 'tour-admin-1'),
|
createMockTournament(2, 'tour-admin-1'),
|
||||||
];
|
];
|
||||||
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
|
eventFindManyMock.mockImplementation(async () => mockTournaments);
|
||||||
|
|
||||||
const result = await getManageableTournaments();
|
const result = await getManageableTournaments();
|
||||||
expect(result).toEqual(mockTournaments);
|
expect(result).toEqual(mockTournaments);
|
||||||
expect(prisma.event.findMany).toHaveBeenCalledWith({
|
expect(eventFindManyMock).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
eventType: 'tournament',
|
eventType: 'tournament',
|
||||||
ownerId: 'tour-admin-1'
|
ownerId: 'tour-admin-1'
|
||||||
@@ -217,11 +228,11 @@ describe('Tournament Permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should return only non-draft tournaments for players', async () => {
|
test('should return only non-draft tournaments for players', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'player-1', email: 'player@example.com' },
|
user: { id: 'player-1', email: 'player@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('player-1', 'player@example.com', 'player')
|
createMockUser('player-1', 'player@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -229,11 +240,11 @@ describe('Tournament Permissions', () => {
|
|||||||
createMockTournament(1, 'user-1'),
|
createMockTournament(1, 'user-1'),
|
||||||
createMockTournament(2, 'user-2'),
|
createMockTournament(2, 'user-2'),
|
||||||
];
|
];
|
||||||
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
|
eventFindManyMock.mockImplementation(async () => mockTournaments);
|
||||||
|
|
||||||
const result = await getManageableTournaments();
|
const result = await getManageableTournaments();
|
||||||
expect(result).toEqual(mockTournaments);
|
expect(result).toEqual(mockTournaments);
|
||||||
expect(prisma.event.findMany).toHaveBeenCalledWith({
|
expect(eventFindManyMock).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
eventType: 'tournament',
|
eventType: 'tournament',
|
||||||
status: { not: 'draft' }
|
status: { not: 'draft' }
|
||||||
@@ -249,14 +260,14 @@ describe('Tournament Permissions', () => {
|
|||||||
// This simulates the scenario where a tournament_admin user
|
// This simulates the scenario where a tournament_admin user
|
||||||
// clicks "Edit" on a tournament they own
|
// clicks "Edit" on a tournament they own
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
||||||
);
|
);
|
||||||
vi.mocked(prisma.event.findUnique).mockResolvedValue(
|
eventFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockTournament(1, 'tour-admin-1')
|
createMockTournament(1, 'tour-admin-1')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -271,11 +282,11 @@ describe('Tournament Permissions', () => {
|
|||||||
test('club_admin should still be able to manage any tournament', async () => {
|
test('club_admin should still be able to manage any tournament', async () => {
|
||||||
// This ensures we didn't break the existing club_admin functionality
|
// This ensures we didn't break the existing club_admin functionality
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'club-admin-1', email: 'club@example.com' },
|
user: { id: 'club-admin-1', email: 'club@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('club-admin-1', 'club@example.com', 'club_admin')
|
createMockUser('club-admin-1', 'club@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -286,13 +297,16 @@ describe('Tournament Permissions', () => {
|
|||||||
test('players should still be denied from managing tournaments', async () => {
|
test('players should still be denied from managing tournaments', async () => {
|
||||||
// This ensures we didn't accidentally grant players access
|
// This ensures we didn't accidentally grant players access
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'player-1', email: 'player@example.com' },
|
user: { id: 'player-1', email: 'player@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('player-1', 'player@example.com', 'player')
|
createMockUser('player-1', 'player@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
eventFindUniqueMock.mockImplementation(async () =>
|
||||||
|
createMockTournament(1, 'other-user-1')
|
||||||
|
);
|
||||||
|
|
||||||
const result = await canManageTournament(1);
|
const result = await canManageTournament(1);
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for tournament update functionality
|
||||||
|
* Tests the allowTies field is properly saved when updating tournaments
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, mock, beforeEach,} from 'bun:test';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// Create mock functions at module level
|
||||||
|
const eventFindUniqueMock = mock(() => {});
|
||||||
|
const eventUpdateMock = mock(() => {});
|
||||||
|
const canManageTournamentMock = mock(() => {});
|
||||||
|
const canDeleteTournamentMock = mock(() => {});
|
||||||
|
|
||||||
|
// Store default implementations
|
||||||
|
const defaultCanManageTournament = canManageTournamentMock.mockResolvedValue({ allowed: true });
|
||||||
|
const defaultCanDeleteTournament = canDeleteTournamentMock.mockResolvedValue({ allowed: true });
|
||||||
|
|
||||||
|
// Mock the prisma client
|
||||||
|
mock.module('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
event: {
|
||||||
|
findUnique: eventFindUniqueMock,
|
||||||
|
update: eventUpdateMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the permissions module
|
||||||
|
mock.module('@/lib/permissions', () => ({
|
||||||
|
canManageTournament: defaultCanManageTournament,
|
||||||
|
canDeleteTournament: defaultCanDeleteTournament,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import the route handler after mocking
|
||||||
|
import { PUT } from '@/app/api/tournaments/[id]/route';
|
||||||
|
|
||||||
|
describe('Tournament Update API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mock history before each test
|
||||||
|
eventFindUniqueMock.mockClear();
|
||||||
|
eventUpdateMock.mockClear();
|
||||||
|
canManageTournamentMock.mockClear();
|
||||||
|
canDeleteTournamentMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update allowTies field when provided', async () => {
|
||||||
|
// Mock existing tournament
|
||||||
|
eventFindUniqueMock.mockImplementation(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Tournament',
|
||||||
|
allowTies: false,
|
||||||
|
targetScore: 5,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
eventDate: null,
|
||||||
|
description: null,
|
||||||
|
maxParticipants: null,
|
||||||
|
ownerId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
// Mock successful update
|
||||||
|
eventUpdateMock.mockImplementation(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Tournament',
|
||||||
|
allowTies: true,
|
||||||
|
targetScore: 5,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
eventDate: null,
|
||||||
|
description: null,
|
||||||
|
maxParticipants: null,
|
||||||
|
ownerId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
const request = new Request('http://localhost/api/tournaments/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Test Tournament',
|
||||||
|
allowTies: true,
|
||||||
|
targetScore: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = Promise.resolve({ id: '1' });
|
||||||
|
const response = await PUT(request, { params });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(prisma.event.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
allowTies: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default allowTies to false when not provided', async () => {
|
||||||
|
// Mock existing tournament
|
||||||
|
eventFindUniqueMock.mockImplementation(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Tournament',
|
||||||
|
allowTies: true,
|
||||||
|
targetScore: 5,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
eventDate: null,
|
||||||
|
description: null,
|
||||||
|
maxParticipants: null,
|
||||||
|
ownerId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
// Mock successful update
|
||||||
|
eventUpdateMock.mockImplementation(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Tournament',
|
||||||
|
allowTies: false,
|
||||||
|
targetScore: 5,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
eventDate: null,
|
||||||
|
description: null,
|
||||||
|
maxParticipants: null,
|
||||||
|
ownerId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
const request = new Request('http://localhost/api/tournaments/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Test Tournament',
|
||||||
|
targetScore: 5,
|
||||||
|
// allowTies not provided
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = Promise.resolve({ id: '1' });
|
||||||
|
const response = await PUT(request, { params });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(prisma.event.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
allowTies: false, // Should default to false
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve allowTies value when updating other fields', async () => {
|
||||||
|
// Mock existing tournament with allowTies = true
|
||||||
|
eventFindUniqueMock.mockImplementation(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Tournament',
|
||||||
|
allowTies: true,
|
||||||
|
targetScore: 5,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
eventDate: null,
|
||||||
|
description: null,
|
||||||
|
maxParticipants: null,
|
||||||
|
ownerId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
// Mock successful update
|
||||||
|
eventUpdateMock.mockImplementation(async () => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Updated Tournament Name',
|
||||||
|
allowTies: true,
|
||||||
|
targetScore: 10,
|
||||||
|
eventType: 'tournament',
|
||||||
|
format: 'round_robin',
|
||||||
|
status: 'planned',
|
||||||
|
eventDate: null,
|
||||||
|
description: null,
|
||||||
|
maxParticipants: null,
|
||||||
|
ownerId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
const request = new Request('http://localhost/api/tournaments/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Updated Tournament Name',
|
||||||
|
allowTies: true,
|
||||||
|
targetScore: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = Promise.resolve({ id: '1' });
|
||||||
|
const response = await PUT(request, { params });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const updateCall = eventUpdateMock.mock.calls[0][0];
|
||||||
|
expect(updateCall.data.allowTies).toBe(true);
|
||||||
|
expect(updateCall.data.name).toBe('Updated Tournament Name');
|
||||||
|
expect(updateCall.data.targetScore).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,25 +4,31 @@
|
|||||||
* Tests for user name editing and profile management
|
* Tests for user name editing and profile management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
import { describe, test, expect, mock, beforeEach,} from 'bun:test';
|
||||||
import { hasRole } from '@/lib/permissions';
|
import { hasRole } from '@/lib/permissions';
|
||||||
import { getSession } from '@/lib/auth-simple';
|
import { getSession } from '@/lib/auth-simple';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import type { User, Player } from '@prisma/client';
|
import type { User, Player } from '@prisma/client';
|
||||||
|
|
||||||
|
// Create mock functions at module level
|
||||||
|
const getSessionMock = mock(() => {});
|
||||||
|
const userFindUniqueMock = mock(() => {});
|
||||||
|
const userUpdateMock = mock(() => {});
|
||||||
|
const playerFindUniqueMock = mock(() => {});
|
||||||
|
|
||||||
// Mock the getSession and prisma functions
|
// Mock the getSession and prisma functions
|
||||||
vi.mock('@/lib/auth-simple', () => ({
|
mock.module('@/lib/auth-simple', () => ({
|
||||||
getSession: vi.fn(),
|
getSession: getSessionMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn(),
|
findUnique: userFindUniqueMock,
|
||||||
update: vi.fn(),
|
update: userUpdateMock,
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
findUnique: vi.fn(),
|
findUnique: playerFindUniqueMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -56,16 +62,20 @@ const createMockPlayer = (id: number, name: string): Player => ({
|
|||||||
|
|
||||||
describe('User Management', () => {
|
describe('User Management', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
// Reset mock implementations to default (no-op) before each test
|
||||||
|
getSessionMock.mockImplementation(() => undefined);
|
||||||
|
userFindUniqueMock.mockImplementation(() => undefined);
|
||||||
|
userUpdateMock.mockImplementation(() => undefined);
|
||||||
|
playerFindUniqueMock.mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('User Name Editing', () => {
|
describe('User Name Editing', () => {
|
||||||
test('club_admin should be able to edit any user name', async () => {
|
test('club_admin should be able to edit any user name', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
createMockUser('admin-1', 'admin@example.com', 'club_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,11 +84,11 @@ describe('User Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('tournament_admin should NOT be able to edit user names', async () => {
|
test('tournament_admin should NOT be able to edit user names', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'tour-admin-1', email: 'tour@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,11 +97,11 @@ describe('User Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('player should NOT be able to edit user names', async () => {
|
test('player should NOT be able to edit user names', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'player-1', email: 'player@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
userFindUniqueMock.mockImplementation(async () =>
|
||||||
createMockUser('player-1', 'player@example.com', 'player')
|
createMockUser('player-1', 'player@example.com', 'player')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,7 +110,7 @@ describe('User Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('unauthenticated user should NOT be able to edit user names', async () => {
|
test('unauthenticated user should NOT be able to edit user names', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(null);
|
getSessionMock.mockImplementation(async () => null);
|
||||||
|
|
||||||
const result = await hasRole('club_admin');
|
const result = await hasRole('club_admin');
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
@@ -111,11 +121,11 @@ describe('User Management', () => {
|
|||||||
test('user should be able to view their own profile', async () => {
|
test('user should be able to view their own profile', async () => {
|
||||||
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
|
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'user-1', email: 'user@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
userFindUniqueMock.mockImplementation(async () => mockUser);
|
||||||
|
|
||||||
// In the actual implementation, this would check if session.user.id === params.id
|
// In the actual implementation, this would check if session.user.id === params.id
|
||||||
const canViewOwnProfile = true; // This logic is in the API route
|
const canViewOwnProfile = true; // This logic is in the API route
|
||||||
@@ -126,11 +136,11 @@ describe('User Management', () => {
|
|||||||
const mockAdmin = createMockUser('admin-1', 'admin@example.com', 'club_admin');
|
const mockAdmin = createMockUser('admin-1', 'admin@example.com', 'club_admin');
|
||||||
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
|
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'admin-1', email: 'admin@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique)
|
(userFindUniqueMock)
|
||||||
.mockResolvedValueOnce(mockAdmin) // For the requesting user
|
.mockResolvedValueOnce(mockAdmin) // For the requesting user
|
||||||
.mockResolvedValueOnce(mockUser); // For the target user
|
.mockResolvedValueOnce(mockUser); // For the target user
|
||||||
|
|
||||||
@@ -142,11 +152,11 @@ describe('User Management', () => {
|
|||||||
test('non-admin should NOT be able to view other user profiles', async () => {
|
test('non-admin should NOT be able to view other user profiles', async () => {
|
||||||
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
|
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'user-1', email: 'user@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
userFindUniqueMock.mockImplementation(async () => mockUser);
|
||||||
|
|
||||||
// In the actual implementation, this would check if session.user.id === params.id
|
// In the actual implementation, this would check if session.user.id === params.id
|
||||||
const canViewOtherProfile = false; // This logic is in the API route
|
const canViewOtherProfile = false; // This logic is in the API route
|
||||||
@@ -159,11 +169,11 @@ describe('User Management', () => {
|
|||||||
const mockUser = createMockUser('user-1', 'user@example.com', 'club_admin');
|
const mockUser = createMockUser('user-1', 'user@example.com', 'club_admin');
|
||||||
const mockPlayer = createMockPlayer(1, 'Old Name');
|
const mockPlayer = createMockPlayer(1, 'Old Name');
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue({
|
getSessionMock.mockImplementation(async () => ({
|
||||||
user: { id: 'user-1', email: 'user@example.com' },
|
user: { id: 'admin-1', email: 'admin@example.com' },
|
||||||
session: { token: 'test', expiresAt: new Date() }
|
session: { token: 'test', expiresAt: new Date() }
|
||||||
});
|
}));
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
|
userFindUniqueMock.mockImplementation(async () => mockUser);
|
||||||
|
|
||||||
const updatedUser = {
|
const updatedUser = {
|
||||||
...mockUser,
|
...mockUser,
|
||||||
@@ -171,7 +181,7 @@ describe('User Management', () => {
|
|||||||
player: { ...mockPlayer, name: 'New Name', normalizedName: 'new name' }
|
player: { ...mockPlayer, name: 'New Name', normalizedName: 'new name' }
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(prisma.user.update).mockResolvedValue(updatedUser);
|
userUpdateMock.mockImplementation(async () => updatedUser);
|
||||||
|
|
||||||
// The API route should update both user.name and player.name
|
// The API route should update both user.name and player.name
|
||||||
expect(updatedUser.name).toBe('New Name');
|
expect(updatedUser.name).toBe('New Name');
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ export default function AdminMatchesPage() {
|
|||||||
{match.team2Score}
|
{match.team2Score}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/matches/${match.id}`}
|
||||||
|
className="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(match.id)}
|
onClick={() => handleDelete(match.id)}
|
||||||
disabled={deletingId === match.id}
|
disabled={deletingId === match.id}
|
||||||
@@ -175,6 +182,7 @@ export default function AdminMatchesPage() {
|
|||||||
>
|
>
|
||||||
{deletingId === match.id ? "Deleting..." : "Delete"}
|
{deletingId === match.id ? "Deleting..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default async function EditUserPage({
|
|||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ user: null },
|
{ user: null },
|
||||||
{ id: user.playerId || -1 }, // Include the currently associated player if any
|
...(user.playerId ? [{ id: user.playerId }] : []), // Include the currently associated player if any
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse playerId - handle empty string case
|
||||||
|
const parsedPlayerId = playerId ? parseInt(playerId) : undefined;
|
||||||
|
const isValidPlayerId = parsedPlayerId !== undefined && !isNaN(parsedPlayerId);
|
||||||
|
|
||||||
// Update the user
|
// Update the user
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -39,14 +43,14 @@ export async function PUT(
|
|||||||
email: email || existingUser.email,
|
email: email || existingUser.email,
|
||||||
name: name !== undefined ? name : existingUser.name,
|
name: name !== undefined ? name : existingUser.name,
|
||||||
role: role || existingUser.role,
|
role: role || existingUser.role,
|
||||||
playerId: playerId ? parseInt(playerId) : null,
|
playerId: isValidPlayerId ? parsedPlayerId : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the player's user relation if needed
|
// Update the player's user relation if needed
|
||||||
if (playerId !== undefined) {
|
if (playerId !== undefined) {
|
||||||
// First, remove user from any existing player
|
// First, remove user from any existing player
|
||||||
if (existingUser.playerId && existingUser.playerId !== parseInt(playerId)) {
|
if (existingUser.playerId && existingUser.playerId !== parsedPlayerId) {
|
||||||
await prisma.player.update({
|
await prisma.player.update({
|
||||||
where: { id: existingUser.playerId },
|
where: { id: existingUser.playerId },
|
||||||
data: { user: { disconnect: true } },
|
data: { user: { disconnect: true } },
|
||||||
@@ -54,9 +58,9 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then, connect user to the new player
|
// Then, connect user to the new player
|
||||||
if (playerId) {
|
if (isValidPlayerId) {
|
||||||
await prisma.player.update({
|
await prisma.player.update({
|
||||||
where: { id: parseInt(playerId) },
|
where: { id: parsedPlayerId! },
|
||||||
data: { user: { connect: { id: user.id } } },
|
data: { user: { connect: { id: user.id } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ export default async function PlayerProfilePage({ params }: PageProps) {
|
|||||||
const totalWins = player.wins
|
const totalWins = player.wins
|
||||||
const winRate = totalGames > 0 ? ((totalWins / totalGames) * 100).toFixed(1) : "0.0"
|
const winRate = totalGames > 0 ? ((totalWins / totalGames) * 100).toFixed(1) : "0.0"
|
||||||
|
|
||||||
// Get best partnership
|
// Get best partnership based on ELO contribution
|
||||||
|
// Best partner is the one who contributed most to your rating (highest totalEloChange)
|
||||||
|
// Must have played at least 2 games together to be considered
|
||||||
const bestPartnership = partnershipStats.reduce((best, stat) => {
|
const bestPartnership = partnershipStats.reduce((best, stat) => {
|
||||||
if (stat.gamesPlayed < 3) return best // Need at least 3 games for partnership to be meaningful
|
if (stat.gamesPlayed < 2) return best // Need at least 2 games for partnership to be meaningful
|
||||||
const currentRate = stat.wins / stat.gamesPlayed
|
const currentEloChange = stat.totalEloChange
|
||||||
const bestRate = best ? best.wins / best.gamesPlayed : 0
|
const bestEloChange = best ? best.totalEloChange : -Infinity
|
||||||
return currentRate > bestRate ? stat : best
|
return currentEloChange > bestEloChange ? stat : best
|
||||||
}, null as (typeof partnershipStats[0] | null))
|
}, null as (typeof partnershipStats[0] | null))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default function EditTournamentForm({ tournament }: EditTournamentFormPro
|
|||||||
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null,
|
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null,
|
||||||
eventDate: formData.eventDate ? new Date(formData.eventDate).toISOString() : null,
|
eventDate: formData.eventDate ? new Date(formData.eventDate).toISOString() : null,
|
||||||
targetScore: formData.targetScore ? parseInt(formData.targetScore) : null,
|
targetScore: formData.targetScore ? parseInt(formData.targetScore) : null,
|
||||||
|
allowTies: formData.allowTies,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
autoSignIn: true, // Automatically sign in after registration
|
autoSignIn: true, // Automatically sign in after registration
|
||||||
requireEmailVerification: false, // Don't require email verification for tests
|
requireEmailVerification: false, // Don't require email verification for tests
|
||||||
|
minPasswordLength: 8, // Set minimum password length
|
||||||
|
maxPasswordLength: 128, // Set maximum password length
|
||||||
},
|
},
|
||||||
secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET,
|
secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET,
|
||||||
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL || "http://localhost:3000",
|
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL || "http://localhost:3000",
|
||||||
|
|||||||
+25
-7
@@ -1,20 +1,38 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
|
||||||
import pg from 'pg'
|
|
||||||
import dotenv from 'dotenv'
|
|
||||||
|
|
||||||
// Load .env file if it exists
|
// Load .env file if it exists
|
||||||
dotenv.config()
|
require('dotenv').config()
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined
|
prisma: PrismaClient | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
// Detect database provider from environment (default to sqlite for local development)
|
||||||
|
const databaseProvider = process.env.DATABASE_PROVIDER || 'sqlite'
|
||||||
|
const databaseUrl = process.env.DATABASE_URL
|
||||||
|
|
||||||
// Create PrismaClient with adapter
|
// Create PrismaClient with appropriate adapter
|
||||||
const createPrismaClient = () => {
|
const createPrismaClient = () => {
|
||||||
const client = new PrismaClient({ adapter })
|
let client: PrismaClient
|
||||||
|
|
||||||
|
if (databaseProvider === 'postgresql') {
|
||||||
|
// Validate DATABASE_URL is present for PostgreSQL
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'DATABASE_URL environment variable is required when DATABASE_PROVIDER is set to postgresql. ' +
|
||||||
|
'Current DATABASE_PROVIDER: ' + databaseProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PrismaPg adapter for PostgreSQL
|
||||||
|
const { PrismaPg } = require('@prisma/adapter-pg')
|
||||||
|
const adapter = new PrismaPg({ connectionString: databaseUrl })
|
||||||
|
client = new PrismaClient({ adapter })
|
||||||
|
} else {
|
||||||
|
// No adapter needed for SQLite
|
||||||
|
client = new PrismaClient()
|
||||||
|
}
|
||||||
|
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,62 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vitest Setup File
|
||||||
|
*
|
||||||
|
* This file runs before each test file in the Vitest environment.
|
||||||
|
* It validates that tests are running against the development database
|
||||||
|
* to prevent accidental data corruption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Load .env file first, then .env.development (which will override .env)
|
||||||
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
|
const envDevPath = path.resolve(process.cwd(), '.env.development');
|
||||||
|
|
||||||
|
// Load base .env file
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
require('dotenv').config({ path: envPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env.development file (will override .env settings)
|
||||||
|
if (fs.existsSync(envDevPath)) {
|
||||||
|
require('dotenv').config({ path: envDevPath, override: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if DATABASE_URL is set and points to dev database
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (databaseUrl) {
|
||||||
|
const isDevDatabase = databaseUrl.includes('euchre_camp_dev');
|
||||||
|
const isProductionDatabase =
|
||||||
|
databaseUrl.includes('euchre_camp') &&
|
||||||
|
!databaseUrl.includes('_dev') &&
|
||||||
|
!databaseUrl.includes('_dev_');
|
||||||
|
|
||||||
|
if (isProductionDatabase) {
|
||||||
|
console.error('');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error('CRITICAL ERROR: Tests are attempting to run against PRODUCTION database!');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error('');
|
||||||
|
console.error('Current DATABASE_URL:', databaseUrl);
|
||||||
|
console.error('');
|
||||||
|
console.error('Tests MUST run against the development database (euchre_camp_dev)');
|
||||||
|
console.error('');
|
||||||
|
console.error('To fix this:');
|
||||||
|
console.error(' 1. Run: npm run test:run');
|
||||||
|
console.error(' 2. Or set: DATABASE_URL environment variable to dev database URL');
|
||||||
|
console.error(' 3. Or load .env.development: source .env.development && npm run test:run');
|
||||||
|
console.error('');
|
||||||
|
console.error('Aborting test execution to prevent data corruption.');
|
||||||
|
console.error('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDevDatabase) {
|
||||||
|
console.log('✓ Tests running against development database (euchre_camp_dev)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('⚠ No DATABASE_URL set - tests may fail or use unexpected database');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user