13 Commits

Author SHA1 Message Date
david 7ee336c51c fix: resolve release workflow version bump issues
Test / unit-tests (push) Has been cancelled
- Fixed bump-version.js to properly handle --yes flag in CI environments
- Added check to skip commit if no changes to package.json or CHANGELOG.md
- Ensured script exits cleanly after version bump with --yes flag
2026-03-31 23:18:18 -07:00
david d24e810ad1 refactor: revert CI image approach and use standard npm ci in workflows
Test / unit-tests (push) Successful in 2m32s
Pull Request / unit-tests (pull_request) Successful in 2m36s
Pull Request / acceptance-tests (pull_request) Failing after 4m59s
Pull Request / analyze-bump-type (pull_request) Has been skipped
The CI image approach with pre-installed dependencies doesn't work with Gitea Actions due to workspace mounting issues. 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.

Changes:
- Reverted pr.yml unit-tests to use node:20-alpine with npm ci
- Updated pr.yml acceptance-tests to use mcr.microsoft.com/playwright image with npm ci
- Updated test.yml to use node:20-alpine with npm ci
- Removed ci-image-builder.yml workflow
- Removed Dockerfile.ci and build-ci-image.sh
- Updated AGENTS.md and README.md to document the deprecation
- Updated justfile to remove CI image commands

The standard approach of running npm ci in each workflow is the recommended approach for Gitea Actions.
2026-03-31 23:10:50 -07:00
david 397594a9df fix: symlink from /app/node_modules to workspace
Test / unit-tests (push) Failing after 53s
Pull Request / unit-tests (pull_request) Failing after 54s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
2026-03-31 23:04:22 -07:00
david 4116884f9f fix: add /app/node_modules to PATH and NODE_PATH
Test / unit-tests (push) Failing after 11s
Pull Request / unit-tests (pull_request) Failing after 10s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
2026-03-31 23:02:56 -07:00
david b445d9044a chore: revert Dockerfile back to /app directory
Test / unit-tests (push) Failing after 8s
Pull Request / unit-tests (pull_request) Failing after 8s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
2026-03-31 23:01:48 -07:00
david 81c93bafc1 fix: use NODE_PATH env var instead of symlinks 2026-03-31 23:01:36 -07:00
david ba180ae6e1 fix: move node_modules to /workspace in CI image and update symlink
Test / unit-tests (push) Failing after 9s
Pull Request / unit-tests (pull_request) Failing after 9s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
2026-03-31 22:57:10 -07:00
david c611c68822 fix: use symlinks to link node_modules from container
Test / unit-tests (push) Failing after 52s
Pull Request / unit-tests (pull_request) Failing after 52s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
2026-03-31 22:53:40 -07:00
david 4c2687498f fix: update workflows to checkout in /app directory
Test / unit-tests (push) Failing after 5s
Pull Request / unit-tests (pull_request) Failing after 6s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
2026-03-31 22:41:11 -07:00
david 3983049d2c fix: use correct docker registry and image name
Pull Request / unit-tests (pull_request) Failing after 9s
Pull Request / acceptance-tests (pull_request) Has been skipped
Pull Request / analyze-bump-type (pull_request) Has been skipped
Test / unit-tests (push) Failing after 8s
2026-03-31 22:19:54 -07:00
david 941d857feb docs: update documentation with CI image info 2026-03-31 22:19:09 -07:00
david b686c3673e chore: add script for building CI image locally 2026-03-31 22:19:06 -07:00
david dfd66cde0b ci: add CI image builder workflow and Dockerfile 2026-03-31 22:19:03 -07:00
52 changed files with 383 additions and 2273 deletions
-15
View File
@@ -1,15 +0,0 @@
# 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"
-54
View File
@@ -1,54 +0,0 @@
# 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)
+1 -11
View File
@@ -1,15 +1,5 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"extends": ["next/core-web-vitals", "next/typescript"],
"ignorePatterns": [
".next",
"out",
-71
View File
@@ -1,71 +0,0 @@
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 }}
+42 -6
View File
@@ -9,7 +9,7 @@ jobs:
unit-tests:
runs-on: ubuntu-latest
container:
image: docker.notsosm.art/euchre-camp/ci-base:latest
image: node:20-alpine
options: --user root
steps:
@@ -17,19 +17,55 @@ jobs:
uses: actions/checkout@v4
- name: Install dependencies
run: bun install
run: npm ci
- name: Run unit tests
run: npm run test:run
acceptance-tests:
runs-on: ubuntu-latest
needs: unit-tests
container:
image: mcr.microsoft.com/playwright:v1.58.0-jammy
options: --user root
env:
DATABASE_PROVIDER: sqlite
DATABASE_URL: file:./prisma/ci.db
BETTER_AUTH_SECRET: test-secret-key-for-ci-only
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: bun x prisma generate
run: npx 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
- name: Setup SQLite database
run: |
# Create SQLite database file
mkdir -p prisma
npx prisma migrate deploy
- name: Run acceptance tests
run: npm run test:acceptance
env:
DATABASE_PROVIDER: sqlite
DATABASE_URL: file:./prisma/ci.db
BETTER_AUTH_SECRET: test-secret-key-for-ci-only
analyze-bump-type:
runs-on: ubuntu-latest
needs: unit-tests
needs: acceptance-tests
steps:
- name: Checkout code
+10 -47
View File
@@ -14,9 +14,6 @@ jobs:
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
@@ -73,42 +70,34 @@ jobs:
echo "Bumping version: $BUMP"
# Run the bump script
bun run scripts/bump-version.js "$BUMP" --yes
node scripts/bump-version.js "$BUMP" --yes
# Get new version
NEW_VERSION=$(bun -e "console.log(require('./package.json').version)")
NEW_VERSION=$(node -p "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
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
git push origin "$TAG_NAME"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build test-capable image
if: steps.commit.outputs.committed == 'true'
run: |
docker build \
--target test-runner \
@@ -117,15 +106,13 @@ jobs:
.
- 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'
npm run test:run
- name: Build production image
if: steps.commit.outputs.committed == 'true'
run: |
docker build \
--target runner \
@@ -135,7 +122,6 @@ jobs:
.
- 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
@@ -159,30 +145,7 @@ jobs:
echo " docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
fi
- name: Deploy to dev environment
if: steps.commit.outputs.committed == 'true'
- name: Deploy to dev (placeholder)
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
# 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
# TODO: Add actual deployment steps
+25
View File
@@ -0,0 +1,25 @@
name: Test
on:
push:
branches:
- '**'
jobs:
unit-tests:
runs-on: ubuntu-latest
container:
image: node:20-alpine
options: --user root
# Skip if this is an auto-generated version bump commit (handled by release workflow)
if: "!contains(github.event.head_commit.message, 'chore: bump version')"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:run
+1 -8
View File
@@ -34,14 +34,7 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Allow example env files to be tracked
!.env.example
!.env.development.example
.env*
# vercel
.vercel
+3 -46
View File
@@ -9,12 +9,11 @@ EuchreCamp is a Next.js 14+ application for managing Euchre tournaments and trac
### Key Technologies
- **Next.js 14+** (App Router)
- **TypeScript**
- **Prisma ORM** (SQLite/PostgreSQL)
- **Prisma ORM** (SQLite)
- **Tailwind CSS**
- **Better Auth** (Authentication)
- **Bun** (Package Manager & Test Runner)
- **Vitest** (Unit Testing)
- **Playwright** (Acceptance Testing)
- **Vitest** (Legacy - migrated to Bun test runner)
## Architecture Patterns
@@ -38,55 +37,13 @@ EuchreCamp is a Next.js 14+ application for managing Euchre tournaments and trac
## 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
To create an admin user, use the provided scripts:
**Option 1: Using Better Auth API (Recommended)**
```bash
bun run scripts/create-admin-via-api.js
node scripts/create-admin-via-api.js
```
This creates the admin user `david@dhg.lol` with password `adminadmin` using Better Auth's internal API.
-53
View File
@@ -1,56 +1,3 @@
## [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
+13 -13
View File
@@ -1,9 +1,9 @@
# Multi-stage build for EuchreCamp Next.js application
# Stage 1: Builder
FROM oven/bun:alpine AS builder
FROM node:20-alpine AS builder
# Install dependencies (needed for native modules)
# Install dependencies
RUN apk add --no-cache python3 make g++
# Set working directory
@@ -13,21 +13,21 @@ WORKDIR /app
COPY package*.json ./
# Install dependencies (including dev dependencies for building)
RUN bun install
RUN npm ci
# Copy source code
COPY . .
# 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
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" bun x prisma generate
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" npx prisma generate
# Build the application (with dummy DATABASE_URL for static page generation and git commit)
ARG GIT_COMMIT=unknown
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" NEXT_PUBLIC_GIT_COMMIT=$GIT_COMMIT bun run build
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" NEXT_PUBLIC_GIT_COMMIT=$GIT_COMMIT npm run build
# Stage 2: Test runner (includes dev dependencies for testing)
FROM oven/bun:alpine AS test-runner
FROM node:20-alpine AS test-runner
# Install dependencies
RUN apk add --no-cache python3 make g++ git
@@ -39,16 +39,16 @@ WORKDIR /app
COPY package*.json ./
# Install ALL dependencies (including dev dependencies for testing)
RUN bun install
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" bun x prisma generate
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" npx prisma generate
# Stage 3: Production runner
FROM oven/bun:alpine AS runner
FROM node:20-alpine AS runner
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
@@ -64,15 +64,15 @@ WORKDIR /app
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/package.json ./package.json
COPY --from=builder --chown=euchre:euchre /app/bun.lockb ./bun.lockb
COPY --from=builder --chown=euchre:euchre /app/package-lock.json ./package-lock.json
COPY --from=builder --chown=euchre:euchre /app/prisma ./prisma
# Install only production dependencies
RUN bun install --production
RUN npm ci --omit=dev
# Generate Prisma client
# Note: We need to set DATABASE_URL even for generation because prisma.config.ts requires it
RUN DATABASE_PROVIDER=postgresql DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" bun x prisma generate
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" npx prisma generate
# Switch to non-root user
USER euchre
@@ -86,4 +86,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Start command
ENTRYPOINT ["dumb-init", "--"]
CMD ["bun", "run", "start"]
CMD ["npm", "start"]
-32
View File
@@ -1,32 +0,0 @@
# 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
-1455
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
[test]
preload = ["./src/__tests__/bun-setup.ts"]
exclude = ["e2e/**", "**/e2e/**"]
# isolation = true
+1 -1
View File
@@ -3,7 +3,7 @@
services:
app:
image: docker.notsosm.art/euchre-camp:0.1.0.dev
image: euchre-camp/euchre-camp:0.1.0.dev
container_name: euchre-camp-app
ports:
- "3000:3000"
-17
View File
@@ -30,23 +30,6 @@
- [ ] Update MatchEditor to use tournament-specific target score
- [ ] Run tests and verify variant scoring implementation
### 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
### Recently Completed ✅
- [x] Fix Prisma build error in CI pipeline (Docker build failure due to missing DATABASE_URL validation)
- [x] Add defensive checks to src/lib/prisma.ts to prevent build failures
- [x] Migrate from npm to Bun package manager
- [x] Migrate unit tests (Vitest → Bun test runner)
- [x] Migrate component tests (Vitest → Bun test runner)
- [x] Configure Bun with DOM environment for React Testing Library
- [x] Keep Playwright for E2E tests (hybrid approach)
### Recently Completed ✅
- [x] Add OpenSkill rating system support (src/lib/openskill-utils.ts)
- [x] Add Glicko2 rating system support (src/lib/glicko2-utils.ts)
-1
View File
@@ -1,5 +1,4 @@
[tools]
bun = "latest"
docker-compose = "latest"
just = "latest"
node = "latest"
+24 -30
View File
@@ -1,42 +1,38 @@
{
"name": "euchre_camp",
"version": "0.1.4",
"version": "0.1.2",
"private": true,
"scripts": {
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
"build": "next build",
"start": "next start",
"lint": "bun run eslint",
"test": "bun test 'src/__tests__/unit/**' 'src/__tests__/*.test.tsx' 'src/__tests__/auth-simple.test.ts'",
"test:unit": "bun test src/__tests__/unit/",
"test:component": "bun test src/__tests__/*.test.tsx",
"test:run": "bun test 'src/__tests__/unit/**' 'src/__tests__/*.test.tsx' 'src/__tests__/auth-simple.test.ts'",
"test:randomize": "bun test src/__tests__/unit/ --randomize",
"test:unit:sequential": "bun test src/__tests__/unit/ --max-concurrency=1",
"test:acceptance": "bun x playwright test e2e/",
"test:acceptance:headed": "bun x playwright test e2e/ --headed",
"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",
"lint": "eslint",
"test": "vitest",
"test:run": "vitest run",
"test:acceptance": "playwright test src/__tests__/e2e/",
"test:acceptance:headed": "playwright test src/__tests__/e2e/ --headed",
"db:switch": "node scripts/switch-database.js",
"db:setup-postgres": "node scripts/setup-postgres.js",
"db:setup-dev": "node scripts/setup-postgres.js",
"db:setup-dev:clean": "node scripts/setup-postgres.js --drop",
"db:reset-dev": "node scripts/reset-dev-db.js",
"db:use-dev": "node scripts/use-dev-db.js",
"db:cleanup-prod": "node scripts/cleanup-prod-db.js",
"db:check-prod": "node scripts/check-test-records.js",
"db:seed": "node scripts/seed.js",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f",
"docker:build": "docker-compose build",
"docker:shell": "docker exec -it euchre-camp-app sh",
"version": "bun run scripts/bump-version.js",
"set-version": "bun run scripts/set-version.js",
"version:patch": "bun run scripts/bump-version.js patch",
"version:minor": "bun run scripts/bump-version.js minor",
"version:major": "bun run scripts/bump-version.js major",
"docker:build:push": "bun run scripts/build-and-push-docker.js",
"docker:compose:generate": "bun run scripts/generate-docker-compose.js",
"release": "bun run version:patch && bun run docker:compose:generate && bun run docker:build:push"
"version": "node scripts/bump-version.js",
"set-version": "node scripts/set-version.js",
"version:patch": "node scripts/bump-version.js patch",
"version:minor": "node scripts/bump-version.js minor",
"version:major": "node scripts/bump-version.js major",
"docker:build:push": "node scripts/build-and-push-docker.js",
"docker:compose:generate": "node scripts/generate-docker-compose.js",
"release": "npm run version:patch && npm run docker:compose:generate && npm run docker:build:push"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -65,8 +61,6 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.3.11",
"@types/jsdom": "^28.0.1",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
@@ -74,7 +68,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"argon2": "^0.44.0",
"eslint": "^8.57.0",
"eslint": "^10.1.0",
"eslint-config-next": "^16.2.1",
"jsdom": "^29.0.1",
"tailwindcss": "^4",
+2 -2
View File
@@ -1,7 +1,7 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testDir: './src/__tests__/e2e',
timeout: 30000,
expect: {
timeout: 5000
@@ -17,7 +17,7 @@ export default defineConfig({
// Reporter to use
reporter: 'html',
// Global setup and teardown
globalSetup: require.resolve('./e2e/global.setup'),
globalSetup: require.resolve('./src/__tests__/e2e/global.setup'),
// Use base URL for relative navigation
use: {
baseURL: 'http://localhost:3000',
+2 -10
View File
@@ -48,22 +48,14 @@ function getCommitsSinceLastTag() {
if (!latestTag) {
// No tags yet, get all commits
const commits = execSync('git log --oneline --format=%s', { encoding: 'utf8' }).trim();
return commits ? commits.split('\n') : [];
return execSync('git log --oneline --format=%s', { encoding: 'utf8' }).trim().split('\n');
}
const commits = execSync(`git log ${latestTag}..HEAD --oneline --format=%s`, { encoding: 'utf8' }).trim();
return commits ? commits.split('\n') : [];
} catch (error) {
// If no commits since tag or other error, get recent commits
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 [];
}
return execSync('git log --oneline --format=%s -n 20', { encoding: 'utf8' }).trim().split('\n');
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ const path = require('path');
const { execSync } = require('child_process');
// Configuration
const REGISTRY = 'docker.notsosm.art';
const REGISTRY = 'euchre-camp';
const IMAGE_NAME = 'euchre-camp';
// Get current version from package.json
+7 -8
View File
@@ -1,25 +1,25 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test'
import { describe, it, expect, vi, beforeEach, MockedFunction } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EditTournamentForm from '@/components/EditTournamentForm'
// Mock next/navigation
mock.module('next/navigation', () => ({
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mock(() => {}),
push: vi.fn(),
}),
}))
// Mock next/link
mock.module('next/link', () => ({
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}))
// Mock fetch
const mockFetch = mock(async () => new Response())
global.fetch = mockFetch as any
const mockFetch = vi.fn()
global.fetch = mockFetch as MockedFunction<typeof global.fetch>
const mockTournament = {
id: 1,
@@ -40,8 +40,7 @@ const mockTournament = {
describe('EditTournamentForm', () => {
beforeEach(() => {
// Only clear the specific mocks we create, not global module mocks
mockFetch.mockClear()
vi.clearAllMocks()
})
it('renders form with initial values', () => {
+26 -36
View File
@@ -5,40 +5,31 @@
* Tests the navigation component for proper display based on session state
*/
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
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.module('@/components/SessionProvider', () => ({
useSession: mock(() => ({ session: null, loading: false, refreshSession: mock(() => {}) })),
vi.mock('@/components/SessionProvider', () => ({
useSession: vi.fn(),
}))
// Mock the auth-client
mock.module('@/lib/auth-client', () => ({
vi.mock('@/lib/auth-client', () => ({
authClient: {
signOut: mock(() => {}),
signOut: vi.fn(),
},
}))
// Mock fetch for role API call
global.fetch = mock(async () => new Response()) as any
global.fetch = vi.fn()
import { useSession as useSessionOriginal } from '@/components/SessionProvider'
const useSession = useSessionOriginal as any
import { useSession } from '@/components/SessionProvider'
describe('Epic 1: Navigation Component', () => {
beforeEach(() => {
// Don't clear all mocks as it might affect bun-setup.ts
// Set up default fetch mock
(global.fetch as any).mockImplementation?.(async (url: any) => {
vi.clearAllMocks()
vi.mocked(global.fetch).mockImplementation(async (url) => {
if (url?.toString().includes('/api/users/')) {
return new Response(JSON.stringify({ role: 'player' }), {
status: 200,
@@ -50,15 +41,14 @@ describe('Epic 1: Navigation Component', () => {
})
afterEach(() => {
// Don't clear mocks - let them persist for other test files
// Navigation relies on module mocks set up at file level
vi.restoreAllMocks()
})
it('renders the logo and basic links when not logged in', () => {
(useSession).mockReturnValue({
vi.mocked(useSession).mockReturnValue({
session: null,
loading: false,
refreshSession: mock(() => {}),
refreshSession: vi.fn(),
})
render(<Navigation />)
@@ -70,7 +60,7 @@ describe('Epic 1: Navigation Component', () => {
})
it('shows user menu when logged in', async () => {
(useSession).mockReturnValue({
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'user-123',
@@ -81,7 +71,7 @@ describe('Epic 1: Navigation Component', () => {
session: { token: 'abc123' },
},
loading: false,
refreshSession: mock(() => {}),
refreshSession: vi.fn(),
})
render(<Navigation />)
@@ -95,7 +85,7 @@ describe('Epic 1: Navigation Component', () => {
})
it('shows Tournaments link when logged in', async () => {
(useSession).mockReturnValue({
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'user-123',
@@ -106,7 +96,7 @@ describe('Epic 1: Navigation Component', () => {
session: { token: 'abc123' },
},
loading: false,
refreshSession: mock(() => {}),
refreshSession: vi.fn(),
})
render(<Navigation />)
@@ -117,7 +107,7 @@ describe('Epic 1: Navigation Component', () => {
})
it('shows admin link for club_admin role', async () => {
(useSession).mockReturnValue({
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'admin-123',
@@ -128,11 +118,11 @@ describe('Epic 1: Navigation Component', () => {
session: { token: 'abc123' },
},
loading: false,
refreshSession: mock(() => {}),
});
refreshSession: vi.fn(),
})
// Mock fetch to return club_admin role
(global.fetch as any).mockImplementation(async (url: any) => {
vi.mocked(global.fetch).mockImplementation(async (url) => {
if (url?.toString().includes('/api/users/admin-123/role')) {
return new Response(JSON.stringify({ role: 'club_admin' }), {
status: 200,
@@ -151,7 +141,7 @@ describe('Epic 1: Navigation Component', () => {
})
it('hides admin link for non-admin users', async () => {
(useSession).mockReturnValue({
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'player-123',
@@ -162,11 +152,11 @@ describe('Epic 1: Navigation Component', () => {
session: { token: 'abc123' },
},
loading: false,
refreshSession: mock(() => {}),
});
refreshSession: vi.fn(),
})
// Mock fetch to return player role (already set in beforeEach, but explicit here)
(global.fetch as any).mockImplementation(async (url: any) => {
vi.mocked(global.fetch).mockImplementation(async (url) => {
if (url?.toString().includes('/api/users/')) {
return {
json: () => Promise.resolve({ role: 'player' }),
@@ -186,10 +176,10 @@ describe('Epic 1: Navigation Component', () => {
})
it('shows loading state', () => {
(useSession).mockReturnValue({
vi.mocked(useSession).mockReturnValue({
session: null,
loading: true,
refreshSession: mock(() => {}),
refreshSession: vi.fn(),
})
render(<Navigation />)
+13 -13
View File
@@ -1,23 +1,23 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test'
import { describe, it, expect, vi, beforeEach, MockedFunction } from 'vitest'
import { getSession } from '@/lib/auth-simple'
// Mock next/headers
mock.module('next/headers', () => ({
cookies: mock(() => Promise.resolve({
get: mock(() => ({ name: 'better-auth.session_token', value: 'test-token' })),
})),
headers: mock(() => Promise.resolve({
get: mock(() => null),
})),
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
get: vi.fn().mockReturnValue({ name: 'better-auth.session_token', value: 'test-token' }),
}),
headers: vi.fn().mockResolvedValue({
get: vi.fn().mockReturnValue(null),
}),
}))
// Mock fetch
const mockFetch = mock(async () => new Response())
global.fetch = mockFetch as any
const mockFetch = vi.fn()
global.fetch = mockFetch as MockedFunction<typeof global.fetch>
describe('getSession', () => {
beforeEach(() => {
mock.clearAllMocks()
vi.clearAllMocks()
})
it('returns session data when auth API returns 200', async () => {
@@ -26,7 +26,7 @@ describe('getSession', () => {
user: { id: 'user-123', email: 'test@example.com' },
}
mockFetch.mockImplementation(async () =>
mockFetch.mockResolvedValue(
new Response(JSON.stringify(mockSession), { status: 200 })
)
@@ -37,7 +37,7 @@ describe('getSession', () => {
})
it('returns null when auth API returns non-200 status', async () => {
mockFetch.mockImplementation(async () =>
mockFetch.mockResolvedValue(
new Response(null, { status: 401 })
)
-40
View File
@@ -1,40 +0,0 @@
// 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 = '';
}
});
@@ -79,7 +79,7 @@ test.describe('CSV Upload Player Deduplication', () => {
formData.append('eventId', testTournamentId.toString());
const response = await request.post('http://localhost:3000/api/matches/upload', {
multipart: formData,
data: formData,
});
expect(response.ok()).toBeTruthy();
@@ -133,7 +133,7 @@ test.describe('CSV Upload Player Deduplication', () => {
formData.append('eventId', testTournamentId.toString());
const response = await request.post('http://localhost:3000/api/matches/upload', {
multipart: formData,
data: formData,
});
expect(response.ok()).toBeTruthy();
@@ -190,7 +190,7 @@ test.describe('CSV Upload Player Deduplication', () => {
formData.append('eventId', testTournamentId.toString());
const response = await request.post('http://localhost:3000/api/matches/upload', {
multipart: formData,
data: formData,
});
expect(response.ok()).toBeTruthy();
@@ -41,7 +41,7 @@ test.describe('Tournament Edit - allowTies functionality', () => {
}
});
test('should display allowTies checkbox on edit form @chromium-admin', async ({ page }) => {
test('should display allowTies checkbox on edit form', async ({ page }) => {
// Navigate to tournament edit page
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
@@ -54,7 +54,7 @@ test.describe('Tournament Edit - allowTies functionality', () => {
await expect(allowTiesCheckbox).not.toBeChecked();
});
test('should save allowTies when toggled to true @chromium-admin', async ({ page }) => {
test('should save allowTies when toggled to true', async ({ page }) => {
// Navigate to tournament edit page
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
@@ -80,7 +80,7 @@ test.describe('Tournament Edit - allowTies functionality', () => {
expect(updatedTournament?.allowTies).toBe(true);
});
test('should save allowTies when toggled to false @chromium-admin', async ({ page }) => {
test('should save allowTies when toggled to false', async ({ page }) => {
// First, set allowTies to true
await prisma.event.update({
where: { id: tournamentId },
-12
View File
@@ -1,12 +0,0 @@
/// <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
> {}
}
+1 -1
View File
@@ -4,7 +4,7 @@
* Tests the mathematical correctness of Elo calculations and rating updates
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect } from 'vitest';
import { calculateEloChange, calculateExpectedScore, calculateTeamElo } from '@/lib/elo-utils';
describe('Elo Rating System', () => {
+1 -1
View File
@@ -4,7 +4,7 @@
* Tests for ID parsing and validation in API routes and pages
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect } from 'vitest';
describe('ID Validation', () => {
describe('parseInt with validation', () => {
+39 -51
View File
@@ -4,33 +4,12 @@
* Tests the permission system for tournament management
*/
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { describe, test, expect, vi } from 'vitest';
import { hasRole, canManageTournament, canCreateTournaments } from '@/lib/permissions';
import { getSession } from '@/lib/auth-simple';
import { prisma } from '@/lib/prisma';
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
const createMockUser = (id: string, email: string, role: string): User => ({
id,
@@ -44,21 +23,30 @@ const createMockUser = (id: string, email: string, role: string): User => ({
updatedAt: new Date(),
});
describe('Permissions', () => {
beforeEach(() => {
// Reset mock implementations to default (no-op) before each test
getSessionMock.mockImplementation(() => undefined);
userFindUniqueMock.mockImplementation(() => undefined);
eventFindUniqueMock.mockImplementation(() => undefined);
});
// 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('hasRole', () => {
test('should allow club_admin to access tournament_admin resources', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('1', 'test@example.com', 'club_admin')
);
@@ -67,11 +55,11 @@ describe('Permissions', () => {
});
test('should deny player from accessing tournament_admin resources', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('1', 'test@example.com', 'player')
);
@@ -80,7 +68,7 @@ describe('Permissions', () => {
});
test('should deny unauthenticated user', async () => {
getSessionMock.mockImplementation(async () => null);
vi.mocked(getSession).mockResolvedValue(null);
const result = await hasRole('club_admin');
expect(result.allowed).toBe(false);
@@ -90,11 +78,11 @@ describe('Permissions', () => {
describe('canManageTournament', () => {
test('should allow club_admin to manage any tournament', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin')
);
@@ -103,11 +91,11 @@ describe('Permissions', () => {
});
test('should deny player from managing tournaments', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player')
);
@@ -119,11 +107,11 @@ describe('Permissions', () => {
describe('canCreateTournaments', () => {
test('should allow tournament_admin to create tournaments', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'tournament_admin')
);
@@ -132,11 +120,11 @@ describe('Permissions', () => {
});
test('should allow club_admin to create tournaments', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin')
);
@@ -145,11 +133,11 @@ describe('Permissions', () => {
});
test('should deny player from creating tournaments', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player')
);
+18 -24
View File
@@ -4,19 +4,15 @@
* Tests the player deduplication logic for CSV uploads
*/
import { describe, test, expect, mock, beforeEach,} from 'bun:test';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { prisma } from '@/lib/prisma';
// Create mock functions at module level
const playerFindFirstMock = mock(() => {});
const playerCreateMock = mock(() => {});
// Mock the prisma module
mock.module('@/lib/prisma', () => ({
vi.mock('@/lib/prisma', () => ({
prisma: {
player: {
findFirst: playerFindFirstMock,
create: playerCreateMock,
findFirst: vi.fn(),
create: vi.fn(),
},
},
}));
@@ -50,9 +46,7 @@ async function findOrCreatePlayer(name: string) {
describe('Player Deduplication', () => {
beforeEach(() => {
// Clear all mock history before each test
playerFindFirstMock.mockClear();
playerCreateMock.mockClear();
vi.clearAllMocks();
});
describe('findOrCreatePlayer', () => {
@@ -70,7 +64,7 @@ describe('Player Deduplication', () => {
rating: 0,
};
playerFindFirstMock.mockImplementation(async () => mockPlayer);
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
const result = await findOrCreatePlayer('Emily');
@@ -95,7 +89,7 @@ describe('Player Deduplication', () => {
rating: 0,
};
playerFindFirstMock.mockImplementation(async () => mockPlayer);
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
const result = await findOrCreatePlayer('EMILY');
@@ -120,7 +114,7 @@ describe('Player Deduplication', () => {
rating: 0,
};
playerFindFirstMock.mockImplementation(async () => mockPlayer);
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
const result = await findOrCreatePlayer(' Emily ');
@@ -132,7 +126,7 @@ describe('Player Deduplication', () => {
});
test('should create new player if not found', async () => {
playerFindFirstMock.mockImplementation(async () => null);
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
const newPlayer = {
id: 100,
@@ -147,7 +141,7 @@ describe('Player Deduplication', () => {
updatedAt: new Date(),
};
playerCreateMock.mockImplementation(async () => newPlayer);
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
const result = await findOrCreatePlayer('NewPlayer');
@@ -168,7 +162,7 @@ describe('Player Deduplication', () => {
});
test('should handle names with special characters', async () => {
playerFindFirstMock.mockImplementation(async () => null);
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
const newPlayer = {
id: 100,
@@ -183,7 +177,7 @@ describe('Player Deduplication', () => {
updatedAt: new Date(),
};
playerCreateMock.mockImplementation(async () => newPlayer);
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
const result = await findOrCreatePlayer('Test-Player_123');
@@ -201,7 +195,7 @@ describe('Player Deduplication', () => {
});
test('should handle names with spaces', async () => {
playerFindFirstMock.mockImplementation(async () => null);
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
const newPlayer = {
id: 100,
@@ -216,7 +210,7 @@ describe('Player Deduplication', () => {
updatedAt: new Date(),
};
playerCreateMock.mockImplementation(async () => newPlayer);
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
const result = await findOrCreatePlayer('Dave B');
@@ -248,7 +242,7 @@ describe('Player Deduplication', () => {
rating: 0,
};
playerFindFirstMock.mockImplementation(async () => mockPlayer);
vi.mocked(prisma.player.findFirst).mockResolvedValue(mockPlayer);
const result1 = await findOrCreatePlayer('EMILY');
const result2 = await findOrCreatePlayer('Emily');
@@ -261,7 +255,7 @@ describe('Player Deduplication', () => {
});
test('should handle empty or whitespace-only names', async () => {
playerFindFirstMock.mockImplementation(async () => null);
vi.mocked(prisma.player.findFirst).mockResolvedValue(null);
const newPlayer = {
id: 100,
@@ -276,7 +270,7 @@ describe('Player Deduplication', () => {
updatedAt: new Date(),
};
playerCreateMock.mockImplementation(async () => newPlayer);
vi.mocked(prisma.player.create).mockResolvedValue(newPlayer);
const result = await findOrCreatePlayer(' ');
@@ -326,7 +320,7 @@ describe('Player Deduplication', () => {
rating: 0,
};
playerFindFirstMock
vi.mocked(prisma.player.findFirst)
.mockResolvedValueOnce(existingPlayer) // First call for "Emily"
.mockResolvedValueOnce(existingPlayer) // Second call for "EMILY"
.mockResolvedValueOnce(existingPlayer); // Third call for " Emily "
+19 -29
View File
@@ -7,30 +7,24 @@
* - Partnership performance
*/
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { prisma } from '@/lib/prisma';
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.module('@/lib/prisma', () => ({
vi.mock('@/lib/prisma', () => ({
prisma: {
player: {
findUnique: playerFindUniqueMock,
findUnique: vi.fn(),
},
event: {
findMany: eventFindManyMock,
findMany: vi.fn(),
},
match: {
findMany: matchFindManyMock,
findMany: vi.fn(),
},
partnershipStat: {
findMany: partnershipStatFindManyMock,
findMany: vi.fn(),
},
},
}));
@@ -117,11 +111,7 @@ const createMockPartnershipStat = (
describe('Player Profile Enhancements', () => {
beforeEach(() => {
// Reset mock implementations to default (no-op) before each test
playerFindUniqueMock.mockImplementation(() => undefined);
eventFindManyMock.mockImplementation(() => undefined);
matchFindManyMock.mockImplementation(() => undefined);
partnershipStatFindManyMock.mockImplementation(() => undefined);
vi.clearAllMocks();
});
describe('Tournaments Participated', () => {
@@ -132,8 +122,8 @@ describe('Player Profile Enhancements', () => {
createMockTournament(2, 'Tournament B'),
];
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
eventFindManyMock.mockImplementation(async () => mockTournaments);
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
// Simulate the query that would be run
const tournaments = await prisma.event.findMany({
@@ -155,8 +145,8 @@ describe('Player Profile Enhancements', () => {
test('should return empty array if player has no tournaments', async () => {
const mockPlayer = createMockPlayer(1, 'Test Player');
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
eventFindManyMock.mockImplementation(async () => []);
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
vi.mocked(prisma.event.findMany).mockResolvedValue([]);
const tournaments = await prisma.event.findMany({
where: {
@@ -183,8 +173,8 @@ describe('Player Profile Enhancements', () => {
createMockMatch(2, 1, 3, 5, 6, 4, 4),
];
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
matchFindManyMock.mockImplementation(async () => mockMatches);
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
vi.mocked(prisma.match.findMany).mockResolvedValue(mockMatches);
// Simulate the query that would be run
const matches = await prisma.match.findMany({
@@ -214,8 +204,8 @@ describe('Player Profile Enhancements', () => {
test('should return empty array if player has no matches', async () => {
const mockPlayer = createMockPlayer(1, 'Test Player');
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
matchFindManyMock.mockImplementation(async () => []);
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
vi.mocked(prisma.match.findMany).mockResolvedValue([]);
const matches = await prisma.match.findMany({
where: {
@@ -250,8 +240,8 @@ describe('Player Profile Enhancements', () => {
createMockPartnershipStat(2, 1, 3, 5, 2, 3),
];
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
partnershipStatFindManyMock.mockImplementation(async () => mockPartnershipStats);
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
vi.mocked(prisma.partnershipStat.findMany).mockResolvedValue(mockPartnershipStats);
// Simulate the query that would be run
const partnershipStats = await prisma.partnershipStat.findMany({
@@ -275,8 +265,8 @@ describe('Player Profile Enhancements', () => {
test('should return empty array if player has no partnership data', async () => {
const mockPlayer = createMockPlayer(1, 'Test Player');
playerFindUniqueMock.mockImplementation(async () => mockPlayer);
partnershipStatFindManyMock.mockImplementation(async () => []);
vi.mocked(prisma.player.findUnique).mockResolvedValue(mockPlayer);
vi.mocked(prisma.partnershipStat.findMany).mockResolvedValue([]);
const partnershipStats = await prisma.partnershipStat.findMany({
where: {
+11 -11
View File
@@ -5,33 +5,33 @@
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, test, expect, beforeEach, mock } from 'bun:test';
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { recalculateAllElo } from '@/lib/elo-utils';
// Mock Prisma client
const mockPrisma = {
player: {
updateMany: mock(() => {}).mockResolvedValue({ count: 0 }),
update: mock(() => {}).mockResolvedValue({}),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue({}),
},
eloSnapshot: {
deleteMany: mock(() => {}).mockResolvedValue({ count: 0 }),
create: mock(() => {}).mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({}),
},
partnershipStat: {
deleteMany: mock(() => {}).mockResolvedValue({ count: 0 }),
findFirst: mock(() => {}).mockResolvedValue(null), // No existing stats initially
update: mock(() => {}).mockResolvedValue({}),
create: mock(() => {}).mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null), // No existing stats initially
update: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
},
match: {
findMany: mock(() => {}).mockResolvedValue([]),
findMany: vi.fn().mockResolvedValue([]),
},
};
describe('recalculateAllElo', () => {
beforeEach(() => {
mock.clearAllMocks();
vi.clearAllMocks();
});
test('should reset all player stats to zero', async () => {
@@ -5,31 +5,25 @@
* Regression tests for the issue where tournament_admin users were redirected to login
*/
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { canManageTournament, ownsTournament, getManageableTournaments } from '@/lib/permissions';
import { getSession } from '@/lib/auth-simple';
import { prisma } from '@/lib/prisma';
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.module('@/lib/auth-simple', () => ({
getSession: getSessionMock,
vi.mock('@/lib/auth-simple', () => ({
getSession: vi.fn(),
}));
mock.module('@/lib/prisma', () => ({
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: userFindUniqueMock,
findUnique: vi.fn(),
},
event: {
findUnique: eventFindUniqueMock,
findMany: eventFindManyMock,
findUnique: vi.fn(),
findMany: vi.fn(),
},
},
}));
@@ -67,21 +61,16 @@ const createMockTournament = (id: number, ownerId: string | null): Event => ({
describe('Tournament Permissions', () => {
beforeEach(() => {
// 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);
vi.clearAllMocks();
});
describe('canManageTournament', () => {
test('should allow club_admin to manage any tournament', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin')
);
@@ -90,14 +79,14 @@ describe('Tournament Permissions', () => {
});
test('should allow tournament_admin to manage their own tournament', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'tour-admin-1', email: 'tour@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
);
eventFindUniqueMock.mockImplementation(async () =>
vi.mocked(prisma.event.findUnique).mockResolvedValue(
createMockTournament(1, 'tour-admin-1')
);
@@ -106,14 +95,14 @@ describe('Tournament Permissions', () => {
});
test('should deny tournament_admin from managing other users tournaments', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'tour-admin-1', email: 'tour@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
);
eventFindUniqueMock.mockImplementation(async () =>
vi.mocked(prisma.event.findUnique).mockResolvedValue(
createMockTournament(1, 'other-user-1')
);
@@ -123,11 +112,11 @@ describe('Tournament Permissions', () => {
});
test('should deny player from managing tournaments', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player')
);
@@ -137,7 +126,7 @@ describe('Tournament Permissions', () => {
});
test('should deny unauthenticated user', async () => {
getSessionMock.mockImplementation(async () => null);
vi.mocked(getSession).mockResolvedValue(null);
const result = await canManageTournament(999);
expect(result.allowed).toBe(false);
@@ -147,11 +136,11 @@ describe('Tournament Permissions', () => {
describe('ownsTournament', () => {
test('should return true if user owns tournament', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'owner-1', email: 'owner@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
eventFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.event.findUnique).mockResolvedValue(
createMockTournament(1, 'owner-1')
);
@@ -160,11 +149,11 @@ describe('Tournament Permissions', () => {
});
test('should return false if user does not own tournament', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'non-owner-1', email: 'nonowner@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
eventFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.event.findUnique).mockResolvedValue(
createMockTournament(1, 'owner-1')
);
@@ -176,11 +165,11 @@ describe('Tournament Permissions', () => {
describe('getManageableTournaments', () => {
test('should return all tournaments for club_admin', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin')
);
@@ -189,11 +178,11 @@ describe('Tournament Permissions', () => {
createMockTournament(2, 'user-2'),
createMockTournament(3, 'user-3'),
];
eventFindManyMock.mockImplementation(async () => mockTournaments);
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
const result = await getManageableTournaments();
expect(result).toEqual(mockTournaments);
expect(eventFindManyMock).toHaveBeenCalledWith({
expect(prisma.event.findMany).toHaveBeenCalledWith({
where: { eventType: 'tournament' },
include: { participants: true },
orderBy: { createdAt: 'desc' }
@@ -201,11 +190,11 @@ describe('Tournament Permissions', () => {
});
test('should return only owned tournaments for tournament_admin', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'tour-admin-1', email: 'tour@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
);
@@ -213,11 +202,11 @@ describe('Tournament Permissions', () => {
createMockTournament(1, 'tour-admin-1'),
createMockTournament(2, 'tour-admin-1'),
];
eventFindManyMock.mockImplementation(async () => mockTournaments);
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
const result = await getManageableTournaments();
expect(result).toEqual(mockTournaments);
expect(eventFindManyMock).toHaveBeenCalledWith({
expect(prisma.event.findMany).toHaveBeenCalledWith({
where: {
eventType: 'tournament',
ownerId: 'tour-admin-1'
@@ -228,11 +217,11 @@ describe('Tournament Permissions', () => {
});
test('should return only non-draft tournaments for players', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player')
);
@@ -240,11 +229,11 @@ describe('Tournament Permissions', () => {
createMockTournament(1, 'user-1'),
createMockTournament(2, 'user-2'),
];
eventFindManyMock.mockImplementation(async () => mockTournaments);
vi.mocked(prisma.event.findMany).mockResolvedValue(mockTournaments);
const result = await getManageableTournaments();
expect(result).toEqual(mockTournaments);
expect(eventFindManyMock).toHaveBeenCalledWith({
expect(prisma.event.findMany).toHaveBeenCalledWith({
where: {
eventType: 'tournament',
status: { not: 'draft' }
@@ -260,14 +249,14 @@ describe('Tournament Permissions', () => {
// This simulates the scenario where a tournament_admin user
// clicks "Edit" on a tournament they own
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'tour-admin-1', email: 'tour@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
);
eventFindUniqueMock.mockImplementation(async () =>
vi.mocked(prisma.event.findUnique).mockResolvedValue(
createMockTournament(1, 'tour-admin-1')
);
@@ -282,11 +271,11 @@ describe('Tournament Permissions', () => {
test('club_admin should still be able to manage any tournament', async () => {
// This ensures we didn't break the existing club_admin functionality
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'club-admin-1', email: 'club@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('club-admin-1', 'club@example.com', 'club_admin')
);
@@ -297,16 +286,13 @@ describe('Tournament Permissions', () => {
test('players should still be denied from managing tournaments', async () => {
// This ensures we didn't accidentally grant players access
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player')
);
eventFindUniqueMock.mockImplementation(async () =>
createMockTournament(1, 'other-user-1')
);
const result = await canManageTournament(1);
expect(result.allowed).toBe(false);
+23 -37
View File
@@ -3,33 +3,23 @@
* Tests the allowTies field is properly saved when updating tournaments
*/
import { describe, it, expect, mock, beforeEach,} from 'bun:test';
import { describe, it, expect, vi, beforeEach } from 'vitest';
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', () => ({
vi.mock('@/lib/prisma', () => ({
prisma: {
event: {
findUnique: eventFindUniqueMock,
update: eventUpdateMock,
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
// Mock the permissions module
mock.module('@/lib/permissions', () => ({
canManageTournament: defaultCanManageTournament,
canDeleteTournament: defaultCanDeleteTournament,
vi.mock('@/lib/permissions', () => ({
canManageTournament: vi.fn().mockResolvedValue({ allowed: true }),
canDeleteTournament: vi.fn().mockResolvedValue({ allowed: true }),
}));
// Import the route handler after mocking
@@ -37,16 +27,12 @@ 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();
vi.clearAllMocks();
});
it('should update allowTies field when provided', async () => {
// Mock existing tournament
eventFindUniqueMock.mockImplementation(async () => ({
vi.mocked(prisma.event.findUnique).mockResolvedValue({
id: 1,
name: 'Test Tournament',
allowTies: false,
@@ -60,10 +46,10 @@ describe('Tournament Update API', () => {
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any));
} as any);
// Mock successful update
eventUpdateMock.mockImplementation(async () => ({
vi.mocked(prisma.event.update).mockResolvedValue({
id: 1,
name: 'Test Tournament',
allowTies: true,
@@ -77,7 +63,7 @@ describe('Tournament Update API', () => {
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any));
} as any);
const request = new Request('http://localhost/api/tournaments/1', {
method: 'PUT',
@@ -92,7 +78,7 @@ describe('Tournament Update API', () => {
const response = await PUT(request, { params });
expect(response.status).toBe(200);
expect(prisma.event.update).toHaveBeenCalledWith(
expect(vi.mocked(prisma.event.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
allowTies: true,
@@ -103,7 +89,7 @@ describe('Tournament Update API', () => {
it('should default allowTies to false when not provided', async () => {
// Mock existing tournament
eventFindUniqueMock.mockImplementation(async () => ({
vi.mocked(prisma.event.findUnique).mockResolvedValue({
id: 1,
name: 'Test Tournament',
allowTies: true,
@@ -117,10 +103,10 @@ describe('Tournament Update API', () => {
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any));
} as any);
// Mock successful update
eventUpdateMock.mockImplementation(async () => ({
vi.mocked(prisma.event.update).mockResolvedValue({
id: 1,
name: 'Test Tournament',
allowTies: false,
@@ -134,7 +120,7 @@ describe('Tournament Update API', () => {
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any));
} as any);
const request = new Request('http://localhost/api/tournaments/1', {
method: 'PUT',
@@ -149,7 +135,7 @@ describe('Tournament Update API', () => {
const response = await PUT(request, { params });
expect(response.status).toBe(200);
expect(prisma.event.update).toHaveBeenCalledWith(
expect(vi.mocked(prisma.event.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
allowTies: false, // Should default to false
@@ -160,7 +146,7 @@ describe('Tournament Update API', () => {
it('should preserve allowTies value when updating other fields', async () => {
// Mock existing tournament with allowTies = true
eventFindUniqueMock.mockImplementation(async () => ({
vi.mocked(prisma.event.findUnique).mockResolvedValue({
id: 1,
name: 'Test Tournament',
allowTies: true,
@@ -174,10 +160,10 @@ describe('Tournament Update API', () => {
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any));
} as any);
// Mock successful update
eventUpdateMock.mockImplementation(async () => ({
vi.mocked(prisma.event.update).mockResolvedValue({
id: 1,
name: 'Updated Tournament Name',
allowTies: true,
@@ -191,7 +177,7 @@ describe('Tournament Update API', () => {
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any));
} as any);
const request = new Request('http://localhost/api/tournaments/1', {
method: 'PUT',
@@ -206,7 +192,7 @@ describe('Tournament Update API', () => {
const response = await PUT(request, { params });
expect(response.status).toBe(200);
const updateCall = eventUpdateMock.mock.calls[0][0];
const updateCall = vi.mocked(prisma.event.update).mock.calls[0][0];
expect(updateCall.data.allowTies).toBe(true);
expect(updateCall.data.name).toBe('Updated Tournament Name');
expect(updateCall.data.targetScore).toBe(10);
+36 -46
View File
@@ -4,31 +4,25 @@
* Tests for user name editing and profile management
*/
import { describe, test, expect, mock, beforeEach,} from 'bun:test';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { hasRole } from '@/lib/permissions';
import { getSession } from '@/lib/auth-simple';
import { prisma } from '@/lib/prisma';
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.module('@/lib/auth-simple', () => ({
getSession: getSessionMock,
vi.mock('@/lib/auth-simple', () => ({
getSession: vi.fn(),
}));
mock.module('@/lib/prisma', () => ({
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: userFindUniqueMock,
update: userUpdateMock,
findUnique: vi.fn(),
update: vi.fn(),
},
player: {
findUnique: playerFindUniqueMock,
findUnique: vi.fn(),
},
},
}));
@@ -62,20 +56,16 @@ const createMockPlayer = (id: number, name: string): Player => ({
describe('User Management', () => {
beforeEach(() => {
// Reset mock implementations to default (no-op) before each test
getSessionMock.mockImplementation(() => undefined);
userFindUniqueMock.mockImplementation(() => undefined);
userUpdateMock.mockImplementation(() => undefined);
playerFindUniqueMock.mockImplementation(() => undefined);
vi.clearAllMocks();
});
describe('User Name Editing', () => {
test('club_admin should be able to edit any user name', async () => {
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin')
);
@@ -84,11 +74,11 @@ describe('User Management', () => {
});
test('tournament_admin should NOT be able to edit user names', async () => {
getSessionMock.mockImplementation(async () => ({
user: { id: 'admin-1', email: 'admin@example.com' },
vi.mocked(getSession).mockResolvedValue({
user: { id: 'tour-admin-1', email: 'tour@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('tour-admin-1', 'tour@example.com', 'tournament_admin')
);
@@ -97,11 +87,11 @@ describe('User Management', () => {
});
test('player should NOT be able to edit user names', async () => {
getSessionMock.mockImplementation(async () => ({
user: { id: 'admin-1', email: 'admin@example.com' },
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () =>
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player')
);
@@ -110,7 +100,7 @@ describe('User Management', () => {
});
test('unauthenticated user should NOT be able to edit user names', async () => {
getSessionMock.mockImplementation(async () => null);
vi.mocked(getSession).mockResolvedValue(null);
const result = await hasRole('club_admin');
expect(result.allowed).toBe(false);
@@ -121,11 +111,11 @@ describe('User Management', () => {
test('user should be able to view their own profile', async () => {
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
getSessionMock.mockImplementation(async () => ({
user: { id: 'admin-1', email: 'admin@example.com' },
vi.mocked(getSession).mockResolvedValue({
user: { id: 'user-1', email: 'user@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () => mockUser);
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
// In the actual implementation, this would check if session.user.id === params.id
const canViewOwnProfile = true; // This logic is in the API route
@@ -136,11 +126,11 @@ describe('User Management', () => {
const mockAdmin = createMockUser('admin-1', 'admin@example.com', 'club_admin');
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
getSessionMock.mockImplementation(async () => ({
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
(userFindUniqueMock)
});
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce(mockAdmin) // For the requesting user
.mockResolvedValueOnce(mockUser); // For the target user
@@ -152,11 +142,11 @@ describe('User Management', () => {
test('non-admin should NOT be able to view other user profiles', async () => {
const mockUser = createMockUser('user-1', 'user@example.com', 'player');
getSessionMock.mockImplementation(async () => ({
user: { id: 'admin-1', email: 'admin@example.com' },
vi.mocked(getSession).mockResolvedValue({
user: { id: 'user-1', email: 'user@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () => mockUser);
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
// In the actual implementation, this would check if session.user.id === params.id
const canViewOtherProfile = false; // This logic is in the API route
@@ -169,11 +159,11 @@ describe('User Management', () => {
const mockUser = createMockUser('user-1', 'user@example.com', 'club_admin');
const mockPlayer = createMockPlayer(1, 'Old Name');
getSessionMock.mockImplementation(async () => ({
user: { id: 'admin-1', email: 'admin@example.com' },
vi.mocked(getSession).mockResolvedValue({
user: { id: 'user-1', email: 'user@example.com' },
session: { token: 'test', expiresAt: new Date() }
}));
userFindUniqueMock.mockImplementation(async () => mockUser);
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
const updatedUser = {
...mockUser,
@@ -181,7 +171,7 @@ describe('User Management', () => {
player: { ...mockPlayer, name: 'New Name', normalizedName: 'new name' }
};
userUpdateMock.mockImplementation(async () => updatedUser);
vi.mocked(prisma.user.update).mockResolvedValue(updatedUser);
// The API route should update both user.name and player.name
expect(updatedUser.name).toBe('New Name');
-2
View File
@@ -14,8 +14,6 @@ export const auth = betterAuth({
enabled: true,
autoSignIn: true, // Automatically sign in after registration
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,
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL || "http://localhost:3000",
+4 -11
View File
@@ -1,7 +1,8 @@
import { PrismaClient } from '@prisma/client'
import dotenv from 'dotenv'
// Load .env file if it exists
require('dotenv').config()
dotenv.config()
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
@@ -9,24 +10,16 @@ const globalForPrisma = globalThis as unknown as {
// 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 appropriate adapter
const createPrismaClient = () => {
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 })
const pg = require('pg')
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
client = new PrismaClient({ adapter })
} else {
// No adapter needed for SQLite