nextjs-rewrite (#5)

Reviewed-on: #5
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
2026-03-30 02:30:13 +00:00
committed by david
parent 1a9b3496e1
commit 123df671f5
160 changed files with 19293 additions and 1180 deletions
@@ -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] } },
})
})
})