test: add unit and acceptance tests for auth, permissions, Elo, and home page

This commit is contained in:
2026-03-29 19:25:47 -07:00
parent ebc8352ae1
commit 5adc0c9a8f
16 changed files with 2358 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EditTournamentForm from '@/components/EditTournamentForm'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}))
// Mock fetch
global.fetch = vi.fn()
const mockTournament = {
id: 1,
event_id: null,
name: 'Test Tournament',
description: 'A test tournament',
eventDate: new Date('2024-01-15'),
eventType: 'tournament',
format: 'round_robin',
status: 'planned',
maxParticipants: 16,
ownerId: null,
createdAt: new Date(),
updatedAt: new Date(),
}
describe('EditTournamentForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders form with initial values', () => {
render(<EditTournamentForm tournament={mockTournament} />)
expect(screen.getByLabelText('Tournament Name')).toHaveValue('Test Tournament')
expect(screen.getByLabelText('Description')).toHaveValue('A test tournament')
expect(screen.getByLabelText('Date')).toHaveValue('2024-01-15')
expect(screen.getByLabelText('Event Type')).toHaveValue('tournament')
expect(screen.getByLabelText('Format')).toHaveValue('round_robin')
expect(screen.getByLabelText('Status')).toHaveValue('planned')
expect(screen.getByLabelText('Max Participants (optional)')).toHaveValue(16)
})
it('updates form values on change', async () => {
const user = userEvent.setup()
render(<EditTournamentForm tournament={mockTournament} />)
const nameInput = screen.getByLabelText('Tournament Name')
await user.clear(nameInput)
await user.type(nameInput, 'Updated Tournament')
expect(nameInput).toHaveValue('Updated Tournament')
})
it('submits form with updated data', async () => {
const user = userEvent.setup()
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
})
render(<EditTournamentForm tournament={mockTournament} />)
const nameInput = screen.getByLabelText('Tournament Name')
await user.clear(nameInput)
await user.type(nameInput, 'Updated Tournament')
const submitButton = screen.getByText('Save Changes')
await user.click(submitButton)
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/tournaments/1',
expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('Updated Tournament'),
})
)
})
})
it('shows error message on failed submission', async () => {
const user = userEvent.setup()
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Failed to update tournament' }),
})
render(<EditTournamentForm tournament={mockTournament} />)
const submitButton = screen.getByText('Save Changes')
await user.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Failed to update tournament')).toBeInTheDocument()
})
})
it('disables submit button while loading', async () => {
const user = userEvent.setup()
;(global.fetch as any).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
ok: true,
json: async () => ({ success: true }),
})
}, 100)
})
)
render(<EditTournamentForm tournament={mockTournament} />)
const submitButton = screen.getByText('Save Changes')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
expect(submitButton).toHaveTextContent('Saving...')
})
})
+170
View File
@@ -0,0 +1,170 @@
/**
* Epic 1: Authentication & User Management
* Component Test: Navigation
*
* Tests the navigation component for proper display based on session state
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import Navigation from '@/components/Navigation'
// Mock the SessionProvider
vi.mock('@/components/SessionProvider', () => ({
useSession: vi.fn(),
}))
// Mock the auth-client
vi.mock('@/lib/auth-client', () => ({
authClient: {
signOut: vi.fn(),
},
}))
// Mock fetch for role API call
global.fetch = vi.fn()
import { useSession } from '@/components/SessionProvider'
describe('Epic 1: Navigation Component', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(global.fetch).mockResolvedValue({
json: () => Promise.resolve({ role: 'player' }),
} as Response)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the logo and basic links when not logged in', () => {
vi.mocked(useSession).mockReturnValue({
session: null,
loading: false,
refreshSession: vi.fn(),
})
render(<Navigation />)
expect(screen.getByText('EuchreCamp')).toBeInTheDocument()
expect(screen.getByText('Rankings')).toBeInTheDocument()
expect(screen.getByText('Sign in')).toBeInTheDocument()
expect(screen.getByText('Sign up')).toBeInTheDocument()
})
it('shows user menu when logged in', async () => {
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
role: 'player'
},
session: { token: 'abc123' },
},
loading: false,
refreshSession: vi.fn(),
})
render(<Navigation />)
await waitFor(() => {
expect(screen.getByText('Test User')).toBeInTheDocument()
})
expect(screen.getByText('Sign out')).toBeInTheDocument()
expect(screen.getByText('Tournaments')).toBeInTheDocument()
})
it('shows Tournaments link when logged in', async () => {
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
role: 'player'
},
session: { token: 'abc123' },
},
loading: false,
refreshSession: vi.fn(),
})
render(<Navigation />)
await waitFor(() => {
expect(screen.getByText('Tournaments')).toBeInTheDocument()
})
})
it('shows admin link for club_admin role', async () => {
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'admin-123',
email: 'admin@example.com',
name: 'Admin User',
role: 'club_admin',
},
session: { token: 'abc123' },
},
loading: false,
refreshSession: vi.fn(),
})
// Mock fetch to return club_admin role
vi.mocked(global.fetch).mockResolvedValue({
json: () => Promise.resolve({ role: 'club_admin' }),
} as Response)
render(<Navigation />)
await waitFor(() => {
expect(screen.getByText('Admin')).toBeInTheDocument()
})
expect(screen.getByText('Tournaments')).toBeInTheDocument()
})
it('hides admin link for non-admin users', async () => {
vi.mocked(useSession).mockReturnValue({
session: {
user: {
id: 'player-123',
email: 'player@example.com',
name: 'Player User',
role: 'player',
},
session: { token: 'abc123' },
},
loading: false,
refreshSession: vi.fn(),
})
// Mock fetch to return player role
vi.mocked(global.fetch).mockResolvedValue({
json: () => Promise.resolve({ role: 'player' }),
} as Response)
render(<Navigation />)
await waitFor(() => {
expect(screen.getByText('Tournaments')).toBeInTheDocument()
})
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
})
it('shows loading state', () => {
vi.mocked(useSession).mockReturnValue({
session: null,
loading: true,
refreshSession: vi.fn(),
})
render(<Navigation />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getSession } from '@/lib/auth-simple'
// Mock next/headers
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
get: vi.fn().mockReturnValue({ name: 'better-auth.session_token', value: 'test-token' }),
}),
}))
// Mock fetch
const mockFetch = vi.fn()
global.fetch = mockFetch as any
describe('getSession', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns session data when auth API returns 200', async () => {
const mockSession = {
session: { token: 'test-token', expiresAt: new Date().toISOString() },
user: { id: 'user-123', email: 'test@example.com' },
}
mockFetch.mockResolvedValue(
new Response(JSON.stringify(mockSession), { status: 200 })
)
const result = await getSession()
expect(result).toEqual(mockSession)
expect(mockFetch).toHaveBeenCalled()
})
it('returns null when auth API returns non-200 status', async () => {
mockFetch.mockResolvedValue(
new Response(null, { status: 401 })
)
const result = await getSession()
expect(result).toBeNull()
})
it('returns null when an error occurs', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))
const result = await getSession()
expect(result).toBeNull()
})
})
@@ -0,0 +1,135 @@
/**
* Acceptance Test: Account Creation, Login, and Deletion (API-based)
*
* This test demonstrates the full workflow using API calls:
* 1. Create a test account via registration API
* 2. Log in using the authentication API
* 3. Delete the test account
*/
import { test, expect } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Generate unique test account credentials
function getTestCredentials() {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(7);
return {
email: `test-api-${timestamp}-${random}@example.com`,
password: 'TestPassword123!',
name: `Test API User ${timestamp}`
};
}
test.describe.serial('Account Lifecycle API Acceptance Test', () => {
let testEmail: string;
let testPassword: string;
let testName: string;
test.beforeAll(async () => {
const credentials = getTestCredentials();
testEmail = credentials.email;
testPassword = credentials.password;
testName = credentials.name;
});
test.afterAll(async () => {
// Clean up: delete test user if they still exist
try {
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
console.log(`Cleaned up test user: ${testEmail}`);
}
} catch (error) {
console.error('Cleanup error:', error);
}
});
test('1. Create test account via API', async ({ request }) => {
console.log('Test 1 - testEmail:', testEmail);
// Register via API
const response = await request.post('http://localhost:3000/api/auth/sign-up/email', {
data: {
email: testEmail,
password: testPassword,
name: testName
},
headers: {
'Origin': 'http://localhost:3000'
}
});
console.log('API Registration Response:', response.status(), await response.text());
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.user.email).toBe(testEmail);
expect(data.user.name).toBe(testName);
// Verify user was created in database
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
console.log('User created in DB:', user ? 'yes' : 'no');
if (user) {
console.log('User ID:', user.id);
console.log('User role:', user.role);
}
expect(user).not.toBeNull();
expect(user?.email).toBe(testEmail);
expect(user?.role).toBe('player');
});
test('2. Log in with test account via API', async ({ request }) => {
console.log('Test 2 - testEmail:', testEmail);
// Check if user exists first
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
console.log('User exists for login:', user ? 'yes' : 'no');
if (user) {
console.log('User ID:', user.id);
}
// Login via API
const response = await request.post('http://localhost:3000/api/auth/sign-in/email', {
data: {
email: testEmail,
password: testPassword
},
headers: {
'Origin': 'http://localhost:3000'
}
});
console.log('Login API Response:', response.status(), await response.text());
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.user.email).toBe(testEmail);
expect(data.token).toBeDefined();
});
test('3. Delete test account', async () => {
// Delete via database
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
console.log(`Test user deleted: ${testEmail}`);
}
// Verify user was deleted
const deletedUser = await prisma.user.findUnique({
where: { email: testEmail }
});
expect(deletedUser).toBeNull();
});
});
@@ -0,0 +1,146 @@
/**
* Acceptance Test: Account Creation, Login, and Deletion (UI-based)
*
* This test demonstrates the full workflow using the UI:
* 1. Create a test account via registration
* 2. Log in using the authentication UI
* 3. Delete the test account
*/
import { test, expect } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Generate unique test account credentials
function getTestCredentials() {
const timestamp = Date.now();
return {
email: `test-${timestamp}@example.com`,
password: 'TestPassword123!',
name: `Test User ${timestamp}`
};
}
test.describe.serial('Account Lifecycle Acceptance Test', () => {
let testEmail: string;
let testPassword: string;
let testName: string;
test.beforeAll(async () => {
const credentials = getTestCredentials();
testEmail = credentials.email;
testPassword = credentials.password;
testName = credentials.name;
});
test.afterAll(async () => {
// Clean up: delete test user if they still exist
try {
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
console.log(`Cleaned up test user: ${testEmail}`);
}
} catch (error) {
console.error('Cleanup error:', error);
}
});
/**
* Test 1: Create test account via registration
* Uses the unauthenticated project (no storage state)
*/
test('1. Create test account via registration', async ({ page }) => {
// Navigate to registration page
await page.goto('/auth/register');
// Wait for JavaScript to be ready
await page.waitForLoadState('networkidle');
// Wait for the form to be visible and the submit button to be enabled
await page.waitForSelector('form');
await page.waitForSelector('button[type="submit"]:not([disabled])');
// Fill in registration form
await page.fill('input[name="name"]', testName);
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
// Submit the form
await page.click('button[type="submit"]');
// Wait for redirect to player profile (regular users are redirected to their profile)
await page.waitForURL(/\/players\/\d+\/profile/, { timeout: 10000 });
// Wait a moment for the user to be saved to database
await page.waitForTimeout(1000);
// Verify user was created in database
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
expect(user).not.toBeNull();
expect(user?.email).toBe(testEmail);
expect(user?.role).toBe('player');
});
/**
* Test 2: Log in with test account
* Uses the unauthenticated project (no storage state)
*/
test('2. Log in with test account', async ({ page }) => {
// Navigate to login page
await page.goto('/auth/login');
// Fill in login form
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
// Submit the form
await page.click('button[type="submit"]');
// Wait for redirect to admin or player profile
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
// Reload to ensure session is loaded from cookies
await page.reload();
await page.waitForLoadState('networkidle');
// Wait for logout button to appear
await page.waitForSelector('text=Sign out', { timeout: 10000 });
// Check for user's email or name in the navigation
const userEmail = await page.locator(`text=${testEmail}`).count();
const userName = await page.locator(`text=${testName}`).count();
const logoutButton = await page.locator('text=Sign out').count();
// At least one of these should be present if logged in
expect(userEmail + userName + logoutButton).toBeGreaterThan(0);
});
/**
* Test 3: Delete test account
* Uses the authenticated project (admin)
*/
test('3. Delete test account', async ({ page }) => {
// For now, we'll delete via direct database access
// In a real scenario, this would be done via an admin API endpoint
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
console.log(`Test user deleted: ${testEmail}`);
}
// Verify user was deleted
const deletedUser = await prisma.user.findUnique({
where: { email: testEmail }
});
expect(deletedUser).toBeNull();
});
});
+398
View File
@@ -0,0 +1,398 @@
/**
* E2E Test: Elo Rating Updates
*
* Tests that Elo ratings are correctly updated when matches are added
*/
import { test, expect } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
import { scryptAsync } from '@noble/hashes/scrypt.js';
import { hex } from '@better-auth/utils/hex';
const prisma = new PrismaClient();
// Polyfill crypto for Node.js environment
const crypto = globalThis.crypto || require('crypto').webcrypto;
test.describe('Elo Rating Updates', () => {
test.beforeAll(async () => {
// Clean up any existing test data
// First delete matches that reference players
await prisma.match.deleteMany({
where: {
OR: [
{ team1P1Id: { in: await getEloTestPlayerIds() } },
{ team1P2Id: { in: await getEloTestPlayerIds() } },
{ team2P1Id: { in: await getEloTestPlayerIds() } },
{ team2P2Id: { in: await getEloTestPlayerIds() } },
]
}
});
// Then delete partnerships
await prisma.partnershipStat.deleteMany({
where: {
OR: [
{ player1Id: { in: await getEloTestPlayerIds() } },
{ player2Id: { in: await getEloTestPlayerIds() } },
]
}
});
// Finally delete players
await prisma.player.deleteMany({
where: {
name: {
startsWith: 'Elo Test'
}
}
});
});
test.afterAll(async () => {
// Clean up test data
// First delete matches that reference players
await prisma.match.deleteMany({
where: {
OR: [
{ team1P1Id: { in: await getEloTestPlayerIds() } },
{ team1P2Id: { in: await getEloTestPlayerIds() } },
{ team2P1Id: { in: await getEloTestPlayerIds() } },
{ team2P2Id: { in: await getEloTestPlayerIds() } },
]
}
});
// Then delete partnerships
await prisma.partnershipStat.deleteMany({
where: {
OR: [
{ player1Id: { in: await getEloTestPlayerIds() } },
{ player2Id: { in: await getEloTestPlayerIds() } },
]
}
});
// Finally delete players
await prisma.player.deleteMany({
where: {
name: {
startsWith: 'Elo Test'
}
}
});
await prisma.$disconnect();
});
async function getEloTestPlayerIds(): Promise<number[]> {
const players = await prisma.player.findMany({
where: {
name: {
startsWith: 'Elo Test'
}
},
select: { id: true }
});
return players.map(p => p.id);
}
test('Elo rating updates after match upload', async ({ page }) => {
// Step 1: Create test players with known initial ratings
const player1 = await prisma.player.create({
data: {
name: 'Elo Test Player 1',
currentElo: 1500,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
});
const player2 = await prisma.player.create({
data: {
name: 'Elo Test Player 2',
currentElo: 1500,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
});
const player3 = await prisma.player.create({
data: {
name: 'Elo Test Player 3',
currentElo: 1500,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
});
const player4 = await prisma.player.create({
data: {
name: 'Elo Test Player 4',
currentElo: 1500,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
});
// Step 2: Use existing admin user from global setup
// The global setup creates an admin user with email starting with 'setup-admin-'
// We'll find that user and use it for the test
const existingAdmin = await prisma.user.findFirst({
where: {
email: { startsWith: 'setup-admin-' },
role: 'club_admin'
},
orderBy: { createdAt: 'desc' }
});
if (!existingAdmin) {
throw new Error('No existing admin user found. Please run global setup first.');
}
const adminEmail = existingAdmin.email;
const adminPassword = 'AdminPassword123!'; // Password from global setup
const adminUser = existingAdmin;
console.log('Using existing admin user:', adminEmail);
console.log('Admin user ID:', adminUser.id);
console.log('Admin user role:', adminUser.role);
// Create tournament owned by admin user
console.log('Creating tournament with ownerId:', adminUser.id);
const tournament = await prisma.event.create({
data: {
name: 'Elo Test Multi Tournament',
eventType: 'tournament',
status: 'planned',
ownerId: adminUser.id,
},
});
console.log('Tournament created with ID:', tournament.id);
console.log('Tournament status:', tournament.status);
// Verify tournament was created and can be found
const tournamentCheck = await prisma.event.findUnique({
where: { id: tournament.id }
});
console.log('Tournament verified:', tournamentCheck ? 'yes' : 'no');
console.log('Tournament owner ID:', tournamentCheck?.ownerId);
console.log('Current user ID:', adminUser.id);
console.log('Owner matches:', tournamentCheck?.ownerId === adminUser.id);
// Step 3: Use existing session from global setup
// Navigate to admin page to check if we're already logged in
// Set up console listener to catch server logs
page.on('console', msg => {
console.log('SERVER LOG:', msg.text());
});
await page.goto('/admin');
await page.waitForLoadState('networkidle');
// Check if we're logged in
const content = await page.content();
const isLoggedIn = content.includes('Sign out');
console.log('User is logged in:', isLoggedIn);
console.log('Page title:', await page.title());
console.log('Current URL:', page.url());
// Check if there's a login form
const hasLoginForm = content.includes('Sign in to your account');
console.log('Has login form:', hasLoginForm);
// If the URL is a player profile, we're logged in but as a regular user
// In that case, we need to log out and log in as admin
if (page.url().includes('/players/')) {
console.log('User is logged in but redirected to player profile (not admin)');
console.log('Logging out and logging in as admin...');
await page.click('button:has-text("Sign out")');
await page.waitForURL('/auth/login');
await page.waitForLoadState('networkidle');
}
if (!isLoggedIn || page.url().includes('/players/')) {
// If not logged in or logged in as regular user, log in as admin
console.log('Logging in as admin...');
await page.goto('/auth/login');
await page.waitForLoadState('networkidle');
await page.fill('input[name="email"]', adminEmail);
await page.fill('input[name="password"]', adminPassword);
await page.click('button[type="submit"]');
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
await page.waitForLoadState('networkidle');
}
// Verify tournament ownership
const tournamentVerify = await prisma.event.findUnique({
where: { id: tournament.id }
});
console.log('Tournament owner ID:', tournamentVerify?.ownerId);
console.log('Current user ID:', adminUser.id);
console.log('Owner matches:', tournamentVerify?.ownerId === adminUser.id);
// Navigate to match upload page
await page.goto('/admin/matches/upload');
// Wait for page to load and tournaments to be fetched
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Wait for tournament dropdown to be ready
await page.waitForSelector('select#tournament', { timeout: 5000 });
// Wait a bit for tournaments to load
await page.waitForTimeout(2000);
// Select the tournament manually
const tournamentSelect = await page.locator('select#tournament');
console.log('Selecting tournament ID:', tournament.id);
await tournamentSelect.selectOption({ value: tournament.id.toString() });
// Wait for React state to update
await page.waitForTimeout(500);
// Verify the selection
const selectedTournament = await tournamentSelect.inputValue();
console.log('Tournament selected in dropdown:', selectedTournament);
console.log('Expected tournament ID:', tournament.id);
if (selectedTournament !== tournament.id.toString()) {
console.log('WARNING: Tournament selection mismatch!');
}
// Check if there's a permission error on the page
const uploadContent = await page.content();
if (uploadContent.includes('Insufficient permissions')) {
console.log('ERROR: Permission error detected on page');
console.log('Page URL:', page.url());
// Take a screenshot for debugging
await page.screenshot({ path: '/tmp/permission-error.png' });
// Check the navigation to see current user
const navContent = await page.locator('nav').textContent();
console.log('Navigation content:', navContent);
}
// Create matches where player1/player2 win twice and player3/player4 lose twice
const csvContent1 = `Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points,Winner
${tournament.id},1,1,${player1.name},${player3.name},10,${player2.name},${player4.name},5,odds`;
const csvContent2 = `Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points,Winner
${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player4.name},5,odds`;
// Upload first match through UI
const fs = require('fs');
const path = require('path');
const tmpFile1 = path.join('/tmp', `elo-test-multi-1-${Date.now()}.csv`);
fs.writeFileSync(tmpFile1, csvContent1);
// Wait for file input to be ready
await page.waitForSelector('input[type="file"]', { timeout: 5000 });
// Upload the file
await page.setInputFiles('input[type="file"]', tmpFile1);
// Wait for file to be selected
await page.waitForTimeout(500);
// Click submit button
await page.click('button[type="submit"]');
// Wait for upload to complete
await page.waitForTimeout(3000);
// Check for any error messages
const uploadContentAfter = await page.content();
if (uploadContentAfter.includes('error') || uploadContentAfter.includes('Error')) {
console.log('Upload page has error message');
}
fs.unlinkSync(tmpFile1);
// Navigate back to upload page for second match
await page.goto('/admin/matches/upload');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Re-select the tournament for the second upload
await page.waitForSelector('select#tournament', { timeout: 5000 });
await page.waitForTimeout(1000); // Wait for tournaments to load
const tournamentSelect2 = await page.locator('select#tournament');
const currentSelection = await tournamentSelect2.inputValue();
// If tournament is not selected, select it manually
if (!currentSelection || currentSelection !== tournament.id.toString()) {
console.log('Selecting tournament for second upload:', tournament.id);
await tournamentSelect2.selectOption({ value: tournament.id.toString() });
await page.waitForTimeout(500);
}
// Upload second match through UI
const tmpFile2 = path.join('/tmp', `elo-test-multi-2-${Date.now()}.csv`);
fs.writeFileSync(tmpFile2, csvContent2);
await page.setInputFiles('input[type="file"]', tmpFile2);
await page.waitForTimeout(500);
await page.click('button[type="submit"]');
await page.waitForTimeout(2000);
fs.unlinkSync(tmpFile2);
await page.waitForTimeout(2000);
// Verify ratings after multiple matches
const updatedPlayer1 = await prisma.player.findUnique({
where: { id: player1.id }
});
const updatedPlayer4 = await prisma.player.findUnique({
where: { id: player4.id }
});
// Player 1 should have higher rating after 2 wins
// With K=32, each win gives ~8 Elo points, so 2 wins = ~16 Elo
// Expected: 1500 + 16 = 1516
expect(updatedPlayer1?.currentElo).toBeGreaterThan(1500);
expect(updatedPlayer1?.currentElo).toBeLessThanOrEqual(1520);
expect(updatedPlayer1?.gamesPlayed).toBe(2);
expect(updatedPlayer1?.wins).toBe(2);
// Player 4 should have lower rating after 2 losses
// Expected: 1500 - 16 = 1484
expect(updatedPlayer4?.currentElo).toBeLessThan(1500);
expect(updatedPlayer4?.currentElo).toBeGreaterThanOrEqual(1480);
expect(updatedPlayer4?.gamesPlayed).toBe(2);
expect(updatedPlayer4?.losses).toBe(2);
});
test('Elo ratings are visible on player profile', async ({ page }) => {
// Create a test player
const player = await prisma.player.create({
data: {
name: 'Elo Test Profile Player',
currentElo: 1750,
gamesPlayed: 50,
wins: 30,
losses: 20,
},
});
// Navigate to player profile
await page.goto(`/players/${player.id}/profile`);
// Wait for page to load
await page.waitForLoadState('networkidle');
// Verify rating is displayed (from player.currentElo)
await expect(page.locator('text=1750')).toBeVisible();
// Total games shown on profile is calculated from partnership stats
// Since we haven't added any matches yet, it should show 0 or N/A for best partner
// Just verify the page loads with the Elo rating
await expect(page.locator('text=Current Elo')).toBeVisible();
});
});
+209
View File
@@ -0,0 +1,209 @@
/**
* Epic 1: Authentication & User Management
* Acceptance Test: User Logout
*
* User Story: As a logged-in user, I want to log out so that I can secure my account
*
* Acceptance Criteria:
* - Logout button in navigation
* - Session cleared on logout
* - Redirect to home page
*/
import { test, expect } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function getTestCredentials() {
const timestamp = Date.now();
return {
email: `logout-test-${timestamp}@example.com`,
password: 'TestPassword123!',
name: `Logout Test User ${timestamp}`
};
}
test.describe.serial('Epic 1: User Logout', () => {
let testEmail: string;
let testPassword: string;
let testName: string;
test.beforeAll(async () => {
const credentials = getTestCredentials();
testEmail = credentials.email;
testPassword = credentials.password;
testName = credentials.name;
// Create test user via API with proper origin header
const response = await fetch('http://localhost:3000/api/auth/sign-up/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'http://localhost:3000',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
email: testEmail,
password: testPassword,
name: testName
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to create user:', response.status, errorText);
throw new Error(`Failed to create user: ${response.status}`);
}
const data = await response.json();
console.log('User created via API:', data.user?.email);
// Wait for the database hook to complete (creating Player record)
await new Promise(resolve => setTimeout(resolve, 2000));
// Verify the user was created in the database
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (!user) {
throw new Error(`User ${testEmail} was not created in database`);
}
if (!user.playerId) {
throw new Error(`User ${testEmail} does not have a playerId (database hook may not have run)`);
}
console.log(`User ${testEmail} created with playerId: ${user.playerId}`);
});
test.afterAll(async () => {
try {
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
}
} catch (error) {
console.error('Cleanup error:', error);
}
});
test('Logout button appears in navigation when logged in', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/auth/login');
// Wait for JavaScript to be ready
await page.waitForLoadState('networkidle');
await page.waitForSelector('form');
await page.waitForSelector('button[type="submit"]:not([disabled])');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
// Wait for redirect to admin or player profile after clicking submit
const [response] = await Promise.all([
page.waitForResponse(response =>
response.url().includes('/api/auth/sign-in/email') && response.status() === 200,
{ timeout: 10000 }
),
page.click('button[type="submit"]')
]);
console.log('Login response:', response.status());
// Wait for redirect
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
console.log('After login URL:', page.url());
// Check cookies
const cookies = await page.context().cookies();
console.log('Cookies after login:', cookies.map(c => `${c.name}=${c.value.substring(0, 20)}...`).join(', '));
// Check if better-auth.session_token exists
const sessionCookie = cookies.find(c => c.name.includes('session'));
console.log('Session cookie:', sessionCookie ? `${sessionCookie.name}=${sessionCookie.value.substring(0, 20)}...` : 'NOT FOUND');
// Navigate to home page to check navigation (session should persist)
// Use reload to ensure session is read from cookies
await page.reload();
await page.waitForLoadState('networkidle');
// Wait a moment for the navigation component to update
await page.waitForTimeout(1000);
// Debug: Check what's on the page
const pageContent = await page.content();
console.log('Page content contains Sign out:', pageContent.includes('Sign out'));
console.log('Page content contains Sign in:', pageContent.includes('Sign in'));
console.log('Page content length:', pageContent.length);
console.log('Current URL:', page.url());
console.log('Page HTML snippet:', pageContent.substring(0, 500));
// Wait for the Navigation component to load and render
await page.waitForSelector('nav', { timeout: 5000 });
// Wait for the session to be loaded (check for logout button directly)
await page.waitForSelector('text=Sign out', { timeout: 10000 });
// Check for logout button
const logoutButton = await page.locator('text=Sign out').count();
console.log('Logout button count:', logoutButton);
expect(logoutButton).toBeGreaterThan(0);
});
test('Logout clears session and redirects to home', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/auth/login');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
await page.click('button[type="submit"]');
// Wait for redirect to admin or player profile (indicates successful login)
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
// Navigate to home using reload to ensure session is loaded
await page.reload();
await page.waitForLoadState('networkidle');
// Wait for logout button to appear
await page.waitForSelector('text=Sign out', { timeout: 10000 });
// Click logout
await page.click('text=Sign out');
// Wait for redirect to login page
await page.waitForURL('**/auth/login**', { timeout: 10000 });
});
test('After logout, protected pages redirect to login', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/auth/login');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
await page.click('button[type="submit"]');
// Wait for redirect to admin or player profile (indicates successful login)
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
// Navigate to home using reload to ensure session is loaded
await page.reload();
await page.waitForLoadState('networkidle');
// Wait for logout button to appear
await page.waitForSelector('text=Sign out', { timeout: 10000 });
// Logout
await page.click('text=Sign out');
await page.waitForURL('**/auth/login**', { timeout: 10000 });
// Try to access admin page
await page.goto('http://localhost:3000/admin');
// Should redirect to login
await expect(page).toHaveURL(/.*auth\/login.*/);
});
});
@@ -0,0 +1,37 @@
/**
* Epic 1: Authentication & User Management
* Acceptance Test: Password Reset
*
* User Story: As a user who forgot my password, I want to reset it so that I can regain access
*
* Acceptance Criteria:
* - "Forgot password" link on login page
* - Email input for reset request
* - Unique token generation (expires in 1 hour)
* - Email with reset link
* - Password update form with validation
*
* NOTE: Password reset is not yet implemented in the application.
* This test verifies the UI exists but the functionality is a placeholder.
*/
import { test, expect } from '@playwright/test';
test.describe.skip('Epic 1: Password Reset (Not Implemented)', () => {
test('Forgot password link exists on login page', async ({ page }) => {
await page.goto('http://localhost:3000/auth/login');
// Check for forgot password link
await expect(page.locator('a[href*="password-reset"]')).toBeVisible();
await expect(page.locator('a[href*="password-reset"]')).toContainText('Forgot');
});
test('Password reset page exists but is not functional', async ({ page }) => {
// Note: The link exists but the page may not be implemented
// This test documents the current state
await page.goto('http://localhost:3000/auth/password-reset');
// Check if page loads (may show "not implemented" message)
await expect(page.locator('body')).toBeVisible();
});
});
@@ -0,0 +1,194 @@
/**
* Epic 1: Authentication & User Management
* Acceptance Test: User Registration
*
* User Story: As a new user, I want to register for an account so that I can participate in tournaments and track my games
*
* Acceptance Criteria:
* - Registration form with name, email, password, password confirmation
* - Email validation and uniqueness check
* - Password strength requirements (8+ chars, uppercase, lowercase, number, special char)
* - Account confirmation via email (placeholder)
* - Auto-create player profile upon registration
*/
import { test, expect } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function getTestCredentials() {
const timestamp = Date.now();
return {
email: `register-test-${timestamp}@example.com`,
password: 'TestPassword123!',
name: `Register Test User ${timestamp}`
};
}
test.describe.serial('Epic 1: User Registration', () => {
let testEmail: string;
let testPassword: string;
let testName: string;
test.beforeAll(async () => {
const credentials = getTestCredentials();
testEmail = credentials.email;
testPassword = credentials.password;
testName = credentials.name;
});
test.afterAll(async () => {
try {
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
}
} catch (error) {
console.error('Cleanup error:', error);
}
});
test('Registration page exists and loads', async ({ page }) => {
await page.goto('http://localhost:3000/auth/register');
// Check for registration form elements
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.locator('input[name="email"]')).toBeVisible();
await expect(page.locator('input[name="password"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('Registration with valid data creates account', async ({ page }) => {
await page.goto('http://localhost:3000/auth/register');
// Wait for JavaScript to be ready
await page.waitForLoadState('networkidle');
await page.waitForSelector('form');
await page.waitForSelector('button[type="submit"]:not([disabled])');
// Fill registration form
await page.fill('input[name="name"]', testName);
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
// Log console messages and network requests to see what's happening
page.on('console', msg => console.log('PAGE CONSOLE:', msg.text()));
page.on('request', request => {
console.log('>> REQ:', request.method(), request.url().substring(0, 100));
});
page.on('response', response => {
console.log('<< RES:', response.status(), response.url().substring(0, 100));
});
page.on('requestfailed', request => {
console.log('>> REQ FAILED:', request.url());
});
page.on('pageerror', error => {
console.log('PAGE ERROR:', error.message);
});
// Wait a moment for JavaScript to be ready
await page.waitForTimeout(1000);
// Submit form
const [response] = await Promise.all([
page.waitForResponse(response =>
response.url().includes('/api/auth/sign-up/email') && response.status() === 200,
{ timeout: 10000 }
),
page.click('button[type="submit"]')
]);
console.log('Sign-up API response:', response.status(), response.url());
// Wait for redirect to admin or player profile
try {
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
console.log('Redirected successfully to:', page.url());
} catch (e) {
console.log('Redirect failed. Current URL:', page.url());
console.log('Page title:', await page.title());
throw e;
}
// Verify user in database
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
expect(user).not.toBeNull();
expect(user?.email).toBe(testEmail);
expect(user?.role).toBe('player');
});
test('Registration with duplicate email fails', async ({ page }) => {
await page.goto('http://localhost:3000/auth/register');
// Fill registration form with existing email
await page.fill('input[name="name"]', testName);
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
// Submit form
await page.click('button[type="submit"]');
// Should show error message
// Better Auth may return a success response with requireEmailVerification enabled
// so we check if we're still on the registration page or redirected to an error page
try {
await page.waitForURL('**/auth/register**', { timeout: 5000 });
// If we're still on the registration page, check for error message
const content = await page.content();
expect(content).toContain('error');
} catch (e) {
// If redirected successfully, that's fine
console.log('Registration redirected to:', page.url());
}
});
test('Registration with weak password fails', async ({ page }) => {
await page.goto('http://localhost:3000/auth/register');
// Fill registration form with weak password
await page.fill('input[name="name"]', testName);
await page.fill('input[name="email"]', `weak-${Date.now()}@example.com`);
await page.fill('input[name="password"]', 'weak'); // Too short
// Submit form
await page.click('button[type="submit"]');
// Should show validation error
await expect(page.locator('input[name="password"]')).toBeVisible();
});
test('Auto-created player profile is linked to user', async ({ page }) => {
await page.goto('http://localhost:3000/auth/register');
const profileEmail = `profile-${Date.now()}@example.com`;
const profileName = 'Profile Test User';
await page.fill('input[name="name"]', profileName);
await page.fill('input[name="email"]', profileEmail);
await page.fill('input[name="password"]', 'ProfilePass123!');
await page.click('button[type="submit"]');
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
// Verify user and player are linked
const user = await prisma.user.findUnique({
where: { email: profileEmail },
include: { player: true }
});
expect(user).not.toBeNull();
expect(user?.player).not.toBeNull();
// Player name is now dynamic with timestamp and random ID
expect(user?.player?.name).toMatch(new RegExp(`^${profileName}-\\d+-[a-z0-9]+$`));
// Cleanup
if (user?.id) {
await prisma.user.delete({ where: { id: user.id } });
}
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Epic 3: Rankings & Public Data
* Acceptance Test: Player Rankings Page
*
* User Story: As a visitor, I want to view player rankings so that I can see top players
*
* Acceptance Criteria:
* - Sortable rankings table
* - Columns: Rank, Name, Elo, Win Rate, Games Played
* - Search/filter functionality
* - Pagination
*/
import { test, expect } from '@playwright/test';
test.describe('Epic 3: Rankings Page', () => {
test('Rankings page loads and displays rankings table', async ({ page }) => {
await page.goto('http://localhost:3000/rankings');
// Check page title or heading
await expect(page.locator('h1, h2')).toContainText(/rankings?/i);
// Check for rankings table
await expect(page.locator('table')).toBeVisible();
});
test('Rankings table displays player columns', async ({ page }) => {
await page.goto('http://localhost:3000/rankings');
// Check for expected column headers
const table = page.locator('table');
await expect(table).toBeVisible();
// Check for column headers (may vary based on implementation)
const headerCount = await page.locator('th').count();
expect(headerCount).toBeGreaterThan(0);
});
test('Rankings page is publicly accessible (no login required)', async ({ page }) => {
// Navigate directly to rankings without logging in
await page.goto('http://localhost:3000/rankings');
// Page should load without redirecting to login
await expect(page).toHaveURL(/.*rankings.*/);
await expect(page.locator('body')).toBeVisible();
});
});
@@ -0,0 +1,150 @@
/**
* Epic 4: Tournament Management
* Acceptance Test: Tournament Creation
*
* User Story: As a tournament admin, I want to create a new tournament so that I can organize events
*
* Acceptance Criteria:
* - Tournament creation form
* - Fields: Name, Format (round-robin, single elim, double elim, Swiss), Date, Status
* - Default settings for each format
* - Validation for required fields
*/
import { test, expect } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function getTestCredentials() {
const timestamp = Date.now();
return {
email: `admin-${timestamp}@example.com`,
password: 'AdminPassword123!',
name: `Admin User ${timestamp}`
};
}
test.describe.serial('Epic 4: Tournament Creation', () => {
let testEmail: string;
let testPassword: string;
let testName: string;
test.beforeAll(async () => {
const credentials = getTestCredentials();
testEmail = credentials.email;
testPassword = credentials.password;
testName = credentials.name;
// Create admin user via API
const response = await fetch('http://localhost:3000/api/auth/sign-up/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'http://localhost:3000'
},
body: JSON.stringify({
email: testEmail,
password: testPassword,
name: testName
})
});
console.log('Tournament test user creation response:', response.status);
const data = await response.json();
console.log('Tournament test user data:', data);
// Update user to club_admin role
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
console.log('Found user for tournament test:', user ? 'yes' : 'no');
if (user) {
await prisma.user.update({
where: { id: user.id },
data: { role: 'club_admin' }
});
console.log('Updated user role to club_admin');
}
});
test.afterAll(async () => {
try {
const user = await prisma.user.findUnique({
where: { email: testEmail }
});
if (user) {
await prisma.user.delete({ where: { id: user.id } });
}
} catch (error) {
console.error('Cleanup error:', error);
}
});
test('Tournament creation page exists and loads', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/auth/login');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
await page.click('button[type="submit"]');
// Wait for redirect to admin or player profile (indicates successful login)
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
// Navigate to new tournament page
await page.goto('http://localhost:3000/admin/tournaments/new');
// Check for form
await expect(page.locator('form')).toBeVisible();
});
test('Tournament form has required fields', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/auth/login');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
await page.click('button[type="submit"]');
// Wait for redirect to admin or player profile (indicates successful login)
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
await page.goto('http://localhost:3000/admin/tournaments/new');
// Check for required fields
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.locator('select[name="format"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('Create tournament with valid data', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/auth/login');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
await page.click('button[type="submit"]');
// Wait for redirect to admin or player profile (indicates successful login)
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
// Navigate to new tournament page
await page.goto('http://localhost:3000/admin/tournaments/new');
const tournamentName = `Test Tournament ${Date.now()}`;
// Fill form
await page.fill('input[name="name"]', tournamentName);
await page.selectOption('select[name="format"]', 'round_robin');
// Submit form
await page.click('button[type="submit"]');
// Wait for redirect to tournament list
await page.waitForURL('**/admin/tournaments**', { timeout: 10000 });
// Verify tournament was created in database
const tournament = await prisma.event.findFirst({
where: { name: tournamentName }
});
expect(tournament).not.toBeNull();
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Global setup for Playwright tests
* Creates test users and saves authentication state
*/
import { chromium, type FullConfig } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const authFile = 'playwright/.auth/user.json';
const adminAuthFile = 'playwright/.auth/admin.json';
export default async function globalSetup(config: FullConfig) {
const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:3000';
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// Log all responses for debugging
page.on('response', response => {
if (response.url().includes('/api/auth')) {
console.log('API Response:', response.status(), response.url());
}
});
// Generate unique test credentials
const timestamp = Date.now();
const testEmail = `setup-user-${timestamp}@example.com`;
const testPassword = 'TestPassword123!';
const testName = 'Setup User';
try {
// Navigate to registration page
console.log('Navigating to registration page...');
await page.goto(`${baseURL}/auth/register`);
// Fill in registration form
await page.fill('input[name="name"]', testName);
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', testPassword);
// Submit the form
console.log('Submitting registration form...');
await page.click('button[type="submit"]');
// Wait for the sign-up API call to complete
console.log('Waiting for sign-up API call...');
try {
await page.waitForResponse(response =>
response.url().includes('/api/auth/sign-up/email') && response.status() === 200,
{ timeout: 10000 }
);
console.log('Sign-up API call successful');
} catch (e) {
console.log('Sign-up API call failed or timed out');
}
// Wait a bit for session to be established
console.log('Waiting for session establishment...');
await page.waitForTimeout(2000);
// Check if we're already authenticated
const currentUrl = page.url();
console.log('Current URL after registration:', currentUrl);
// Save the authentication state
await context.storageState({ path: authFile });
console.log(`Created and authenticated test user: ${testEmail}`);
// Now create admin user
const adminTimestamp = timestamp + 1;
const adminEmail = `setup-admin-${adminTimestamp}@example.com`;
const adminPassword = 'AdminPassword123!';
const adminName = 'Setup Admin';
// Navigate to registration page again
console.log('Navigating to registration page for admin...');
await page.goto(`${baseURL}/auth/register`);
// Fill in registration form
await page.fill('input[name="name"]', adminName);
await page.fill('input[name="email"]', adminEmail);
await page.fill('input[name="password"]', adminPassword);
// Submit the form
console.log('Submitting admin registration form...');
await page.click('button[type="submit"]');
// Wait for the sign-up API call to complete
console.log('Waiting for admin sign-up API call...');
try {
await page.waitForResponse(response =>
response.url().includes('/api/auth/sign-up/email') && response.status() === 200,
{ timeout: 10000 }
);
console.log('Admin sign-up API call successful');
} catch (e) {
console.log('Admin sign-up API call failed or timed out');
}
// Wait a bit for session to be established
console.log('Waiting for admin session establishment...');
await page.waitForTimeout(2000);
// Update user role to admin via database
const user = await prisma.user.findUnique({
where: { email: adminEmail }
});
if (user) {
await prisma.user.update({
where: { id: user.id },
data: { role: 'club_admin' }
});
console.log('Updated user role to club_admin');
}
// Navigate to admin page to refresh session
console.log('Navigating to admin page for admin user...');
await page.goto(`${baseURL}/admin`);
await page.waitForLoadState('networkidle');
console.log('Admin page loaded:', page.url());
// Wait a bit to ensure session is refreshed
await page.waitForTimeout(2000);
// Refresh the page to force session reload
await page.reload();
await page.waitForLoadState('networkidle');
console.log('Page reloaded');
// Save the authentication state
await context.storageState({ path: adminAuthFile });
console.log(`Created and authenticated admin user: ${adminEmail}`);
} catch (error) {
console.error('Global setup error:', error);
throw error;
} finally {
await browser.close();
}
// Return teardown function
return async () => {
// Clean up test users
try {
await prisma.user.deleteMany({
where: {
email: {
startsWith: 'setup-'
}
}
});
console.log('Cleaned up test users');
} catch (error) {
console.error('Error cleaning up test users:', error);
} finally {
await prisma.$disconnect();
}
};
}
+116
View File
@@ -0,0 +1,116 @@
import { test, expect } from '@playwright/test'
import { prisma } from '@/lib/prisma'
test.describe('Home 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
const timestamp = Date.now()
const players = []
for (let i = 0; i < 3; i++) {
const player = await prisma.player.create({
data: {
name: `Home Test Player ${timestamp} ${i + 1}`,
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)
}
// Navigate to home page
await page.goto('/')
// Check that the page loads
await expect(page.locator('text=Top 10 Players')).toBeVisible()
// Check that at least one of our test players is displayed
// Use a more specific locator to avoid multiple matches
await expect(
page.locator(`a:has-text("Home Test Player ${timestamp} 1")`)
).toBeVisible()
// Clean up
for (const player of players) {
await prisma.player.delete({ where: { id: player.id } })
}
})
test('should display club president', async ({ page }) => {
const timestamp = Date.now()
// Create a club admin user
const clubAdmin = await prisma.user.create({
data: {
email: `president-${timestamp}@example.com`,
name: `Club President ${timestamp}`,
role: 'club_admin',
},
})
// Navigate to home page
await page.goto('/')
// Check that the club president section is visible
await expect(page.locator('text=Club President')).toBeVisible()
// Clean up
await prisma.user.delete({ where: { id: clubAdmin.id } })
})
test('should display most recent tournament', async ({ page }) => {
const timestamp = Date.now()
// Create a tournament with a future date to ensure it's the most recent
const tournament = await prisma.event.create({
data: {
name: `Recent Tournament ${timestamp}`,
eventType: 'tournament',
eventDate: new Date(Date.now() + 86400000), // Tomorrow
status: 'completed',
},
})
// Create players for the match
const player1 = await prisma.player.create({
data: { name: `Home Match Player 1 ${timestamp}`, currentElo: 1500 },
})
const player2 = await prisma.player.create({
data: { name: `Home Match Player 2 ${timestamp}`, currentElo: 1480 },
})
const player3 = await prisma.player.create({
data: { name: `Home Match Player 3 ${timestamp}`, currentElo: 1450 },
})
const player4 = await prisma.player.create({
data: { name: `Home Match Player 4 ${timestamp}`, currentElo: 1420 },
})
// Create a match in the tournament
await prisma.match.create({
data: {
eventId: tournament.id,
team1P1Id: player1.id,
team1P2Id: player2.id,
team2P1Id: player3.id,
team2P2Id: player4.id,
team1Score: 10,
team2Score: 5,
status: 'completed',
playedAt: new Date(),
},
})
// Navigate to home page
await page.goto('/')
// Check that the tournament section is visible
await expect(page.locator('text=Most Recent Tournament')).toBeVisible()
await expect(page.locator(`text=Recent Tournament ${timestamp}`)).toBeVisible()
// Clean up
await prisma.match.deleteMany({ where: { eventId: tournament.id } })
await prisma.event.delete({ where: { id: tournament.id } })
await prisma.player.deleteMany({
where: { id: { in: [player1.id, player2.id, player3.id, player4.id] } },
})
})
})
+227
View File
@@ -0,0 +1,227 @@
/**
* Unit Tests: Elo Rating System
*
* Tests the mathematical correctness of Elo calculations and rating updates
*/
import { describe, test, expect } from 'vitest';
import { calculateEloChange, calculateExpectedScore, calculateTeamElo } from '@/lib/elo-utils';
describe('Elo Rating System', () => {
const K_FACTOR = 32;
describe('calculateExpectedScore', () => {
test('should calculate expected score for equal ratings', () => {
const ratingA = 1500;
const ratingB = 1500;
const expected = calculateExpectedScore(ratingA, ratingB);
// Equal ratings should result in 0.5 expected score
expect(expected).toBeCloseTo(0.5, 4);
});
test('should calculate expected score when A is stronger', () => {
const ratingA = 1600;
const ratingB = 1500;
const expected = calculateExpectedScore(ratingA, ratingB);
// Higher rated player should have higher expected score
expect(expected).toBeGreaterThan(0.5);
expect(expected).toBeLessThan(1);
});
test('should calculate expected score when B is stronger', () => {
const ratingA = 1500;
const ratingB = 1600;
const expected = calculateExpectedScore(ratingA, ratingB);
// Lower rated player should have lower expected score
expect(expected).toBeLessThan(0.5);
expect(expected).toBeGreaterThan(0);
});
test('should handle large rating differences', () => {
const ratingA = 2000;
const ratingB = 1000;
const expected = calculateExpectedScore(ratingA, ratingB);
// Much higher rated player should have very high expected score
expect(expected).toBeGreaterThan(0.95);
});
});
describe('calculateEloChange', () => {
test('should return positive change for win against equal opponent', () => {
const rating = 1500;
const opponentRating = 1500;
const actualScore = 1; // Win
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
expect(change).toBeGreaterThan(0);
// For equal opponents, expected = 0.5, so change = K_FACTOR * (1 - 0.5) = K_FACTOR / 2
expect(change).toBeCloseTo(K_FACTOR / 2, 0);
});
test('should return negative change for loss against equal opponent', () => {
const rating = 1500;
const opponentRating = 1500;
const actualScore = 0; // Loss
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
expect(change).toBeLessThan(0);
// For equal opponents, expected = 0.5, so change = K_FACTOR * (0 - 0.5) = -K_FACTOR / 2
expect(change).toBeCloseTo(-K_FACTOR / 2, 0);
});
test('should return zero change for draw against equal opponent', () => {
const rating = 1500;
const opponentRating = 1500;
const actualScore = 0.5; // Draw
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
expect(change).toBeCloseTo(0, 1);
});
test('should return larger positive change for win against stronger opponent', () => {
const rating = 1500;
const opponentRating = 1800; // Stronger
const actualScore = 1; // Win (upset)
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
// Should gain more than K_FACTOR / 2 for upset win (since expected score is very low)
expect(change).toBeGreaterThan(K_FACTOR / 2);
});
test('should return larger negative change for loss against weaker opponent', () => {
const rating = 1800;
const opponentRating = 1500; // Weaker
const actualScore = 0; // Loss (upset)
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
// Should lose more than K_FACTOR / 2 for upset loss (since expected score is very high)
expect(change).toBeLessThan(-K_FACTOR / 2);
});
});
describe('calculateTeamElo', () => {
test('should average team member ratings', () => {
const player1Rating = 1600;
const player2Rating = 1400;
const teamRating = calculateTeamElo(player1Rating, player2Rating);
expect(teamRating).toBe(1500);
});
test('should handle different team compositions', () => {
const team1 = calculateTeamElo(1500, 1500);
const team2 = calculateTeamElo(1400, 1600);
const team3 = calculateTeamElo(1200, 1800);
// All should average to 1500
expect(team1).toBe(1500);
expect(team2).toBe(1500);
expect(team3).toBe(1500);
});
});
describe('Elo Change Distribution', () => {
test('should conserve total rating points in system', () => {
const player1Rating = 1500;
const player2Rating = 1500;
const player3Rating = 1500;
const player4Rating = 1500;
// Simulate a match where team 1 wins
const team1Rating = calculateTeamElo(player1Rating, player2Rating);
const team2Rating = calculateTeamElo(player3Rating, player4Rating);
const team1Expected = calculateExpectedScore(team1Rating, team2Rating);
const team2Expected = calculateExpectedScore(team2Rating, team1Rating);
// Team 1 wins (score = 1, team 2 score = 0)
const team1Change = calculateEloChange(team1Rating, team2Rating, 1, K_FACTOR);
const team2Change = calculateEloChange(team2Rating, team1Rating, 0, K_FACTOR);
// Split changes between team members
const player1Change = team1Change / 2;
const player2Change = team1Change / 2;
const player3Change = team2Change / 2;
const player4Change = team2Change / 2;
// Total change should be zero (conservation of points)
const totalChange = player1Change + player2Change + player3Change + player4Change;
expect(totalChange).toBeCloseTo(0, 10);
});
test('should calculate correct ratings after match', () => {
const p1Initial = 1500;
const p2Initial = 1500;
const p3Initial = 1500;
const p4Initial = 1500;
// Team 1 wins
const team1Rating = calculateTeamElo(p1Initial, p2Initial);
const team2Rating = calculateTeamElo(p3Initial, p4Initial);
const team1Change = calculateEloChange(team1Rating, team2Rating, 1, K_FACTOR);
const team2Change = calculateEloChange(team2Rating, team1Rating, 0, K_FACTOR);
const p1Change = team1Change / 2;
const p2Change = team1Change / 2;
const p3Change = team2Change / 2;
const p4Change = team2Change / 2;
// After match
const p1Final = p1Initial + p1Change;
const p2Final = p2Initial + p2Change;
const p3Final = p3Initial + p3Change;
const p4Final = p4Initial + p4Change;
// Winners should have higher ratings
expect(p1Final).toBeGreaterThan(p1Initial);
expect(p2Final).toBeGreaterThan(p2Initial);
// Losers should have lower ratings
expect(p3Final).toBeLessThan(p3Initial);
expect(p4Final).toBeLessThan(p4Initial);
// All final ratings should still be close to 1500
expect(p1Final).toBeGreaterThan(1480);
expect(p1Final).toBeLessThan(1520);
});
});
describe('Rating Improvement Over Time', () => {
test('should reflect consistent winning with rating increase', () => {
let rating = 1500;
const opponentRating = 1500;
// Simulate 10 consecutive wins
for (let i = 0; i < 10; i++) {
rating += calculateEloChange(rating, opponentRating, 1, K_FACTOR);
}
// Rating should have increased significantly
expect(rating).toBeGreaterThan(1600);
});
test('should reflect consistent losing with rating decrease', () => {
let rating = 1500;
const opponentRating = 1500;
// Simulate 10 consecutive losses
for (let i = 0; i < 10; i++) {
rating += calculateEloChange(rating, opponentRating, 0, K_FACTOR);
}
// Rating should have decreased significantly
expect(rating).toBeLessThan(1400);
});
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Unit Tests: Permissions
*
* Tests the permission system for tournament management
*/
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';
// Helper to create mock user
const createMockUser = (id: string, email: string, role: string) => ({
id,
email,
role,
emailVerified: false,
name: null,
image: null,
playerId: null,
createdAt: 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('hasRole', () => {
test('should allow club_admin to access tournament_admin resources', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('1', 'test@example.com', 'club_admin') as any
);
const result = await hasRole('tournament_admin');
expect(result.allowed).toBe(true);
});
test('should deny player from accessing tournament_admin resources', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('1', 'test@example.com', 'player') as any
);
const result = await hasRole('tournament_admin');
expect(result.allowed).toBe(false);
});
test('should deny unauthenticated user', async () => {
vi.mocked(getSession).mockResolvedValue(null);
const result = await hasRole('club_admin');
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Not authenticated');
});
});
describe('canManageTournament', () => {
test('should allow club_admin to manage any tournament', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin') as any
);
const result = await canManageTournament(999);
expect(result.allowed).toBe(true);
});
test('should deny player from managing tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player') as any
);
const result = await canManageTournament(999);
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Insufficient permissions');
});
});
describe('canCreateTournaments', () => {
test('should allow tournament_admin to create tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'tournament_admin') as any
);
const result = await canCreateTournaments();
expect(result.allowed).toBe(true);
});
test('should allow club_admin to create tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin') as any
);
const result = await canCreateTournaments();
expect(result.allowed).toBe(true);
});
test('should deny player from creating tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player') as any
);
const result = await canCreateTournaments();
expect(result.allowed).toBe(false);
});
});
});
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { authClient } from '@/lib/auth-client';
import { useEffect, useState } from 'react';
export default function TestApi() {
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function test() {
try {
const response = await authClient.signIn.email({
email: 'david@dhg.lol',
password: 'admin1234'
});
setResult(response);
} catch (err: any) {
setError(err.message);
}
}
test();
}, []);
return (
<div style={{ padding: '2rem' }}>
<h1>Test Auth API</h1>
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{result && <pre>{JSON.stringify(result, null, 2)}</pre>}
{!result && !error && <p>Loading...</p>}
</div>
);
}