refactor: improve test structure for Bun compatibility
- Update all test files to use named mock variables instead of inline mocks - Clear mock history in beforeEach instead of using mock.restore() - Add default mock implementations stored at module level - Document that tests should not use --randomize flag due to mock.module() limitations - All 89 unit tests pass consistently without randomization
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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 { prisma } from '@/lib/prisma';
|
||||
|
||||
// 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,144 @@
|
||||
/**
|
||||
* 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 { prisma } from '@/lib/prisma';
|
||||
|
||||
// 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 () => {
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Admin Features Test: Match and Player Deletion
|
||||
*
|
||||
* This test suite specifically tests the new admin features:
|
||||
* - Match deletion from admin panel
|
||||
* - Player deletion from admin panel
|
||||
*
|
||||
* @chromium-admin - Tests that require admin authentication
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
test.describe('Admin Features: Match and Player Management @chromium-admin', () => {
|
||||
test.describe('Match Management', () => {
|
||||
test('should access matches admin page', async ({ page }) => {
|
||||
await page.goto('/admin/matches')
|
||||
|
||||
// Verify page loads
|
||||
await expect(page.locator('text=Match Management')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show delete buttons for matches', async ({ page }) => {
|
||||
await page.goto('/admin/matches')
|
||||
|
||||
// Verify delete buttons exist in the table
|
||||
const deleteButtons = page.locator('button:has-text("Delete")')
|
||||
const count = await deleteButtons.count()
|
||||
// Table might be empty, but the column should exist
|
||||
await expect(page.locator('text=Actions')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have proper match data structure', async ({ page }) => {
|
||||
await page.goto('/admin/matches')
|
||||
|
||||
// Verify table headers (using more specific selectors)
|
||||
await expect(page.locator('th:has-text("Date")')).toBeVisible()
|
||||
await expect(page.locator('th:has-text("Tournament")')).toBeVisible()
|
||||
await expect(page.locator('th:has-text("Team 1")')).toBeVisible()
|
||||
await expect(page.locator('th:has-text("Score")').first()).toBeVisible()
|
||||
await expect(page.locator('th:has-text("Team 2")')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Player Management', () => {
|
||||
test('should access players admin page', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
|
||||
// Verify page loads
|
||||
await expect(page.locator('text=Player Management')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show edit and delete buttons for players', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
|
||||
// Verify action buttons exist
|
||||
await expect(page.locator('text=Actions')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have proper player data structure', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
|
||||
// Verify table headers
|
||||
await expect(page.locator('text=Player Name')).toBeVisible()
|
||||
await expect(page.locator('text=Current Elo')).toBeVisible()
|
||||
await expect(page.locator('text=Games')).toBeVisible()
|
||||
await expect(page.locator('text=Record')).toBeVisible()
|
||||
await expect(page.locator('text=Win Rate')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('API Endpoints', () => {
|
||||
test('should have matches API endpoint', async ({ page }) => {
|
||||
// Try to access the matches API
|
||||
const response = await page.request.get('/api/matches')
|
||||
expect(response.ok()).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should have players admin API endpoint', async ({ page }) => {
|
||||
// Try to access the players API
|
||||
const response = await page.request.get('/api/players')
|
||||
expect(response.ok()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation Updates', () => {
|
||||
test('should show admin links when authenticated', async ({ page }) => {
|
||||
// Go to admin page (already authenticated via chromium-admin project)
|
||||
await page.goto('/admin')
|
||||
|
||||
// Verify admin links ARE visible (using exact text matching)
|
||||
await expect(page.locator('a:has-text("Admin")').first()).toBeVisible()
|
||||
await expect(page.locator('a:has-text("Matches")').first()).toBeVisible()
|
||||
await expect(page.locator('a:has-text("Players")').first()).toBeVisible()
|
||||
await expect(page.locator('a:has-text("Users")').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* E2E Test: CSV Upload Player Deduplication
|
||||
*
|
||||
* Tests that CSV uploads correctly deduplicate players by name
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
test.describe('CSV Upload Player Deduplication', () => {
|
||||
let testTournamentId: number;
|
||||
const testPlayerIds: number[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create a test tournament
|
||||
const tournament = await prisma.event.create({
|
||||
data: {
|
||||
name: 'Deduplication Test Tournament',
|
||||
eventDate: new Date(),
|
||||
eventType: 'tournament',
|
||||
format: 'round_robin',
|
||||
status: 'planned',
|
||||
},
|
||||
});
|
||||
testTournamentId = tournament.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up test data
|
||||
if (testTournamentId) {
|
||||
// Delete matches first (they reference players)
|
||||
await prisma.match.deleteMany({
|
||||
where: { eventId: testTournamentId },
|
||||
});
|
||||
|
||||
// Delete event participants
|
||||
await prisma.eventParticipant.deleteMany({
|
||||
where: { eventId: testTournamentId },
|
||||
});
|
||||
|
||||
// Delete test tournament
|
||||
await prisma.event.delete({
|
||||
where: { id: testTournamentId },
|
||||
});
|
||||
}
|
||||
|
||||
// Delete test players (those with "Dedupe" in the name)
|
||||
await prisma.player.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: 'Dedupe',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test('should not create duplicate players with different cases', async ({ request }) => {
|
||||
// Create a CSV with the same player name in different cases
|
||||
const csvContent = `Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points
|
||||
1,1,1,Dedupe Player,Dedupe Player2,5,Dedupe player,Dedupe PLAYER2,3`;
|
||||
|
||||
// Create a temporary CSV file
|
||||
const csvPath = path.join(__dirname, 'temp-deduplication-test.csv');
|
||||
fs.writeFileSync(csvPath, csvContent);
|
||||
|
||||
try {
|
||||
// Upload the CSV
|
||||
const csvFileContent = fs.readFileSync(csvPath, 'utf-8');
|
||||
const blob = new Blob([csvFileContent], { type: 'text/csv' });
|
||||
const file = new File([blob], 'deduplication-test.csv', { type: 'text/csv' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('csvFile', file);
|
||||
formData.append('eventId', testTournamentId.toString());
|
||||
|
||||
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
||||
data: formData,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
// Check that only 4 unique players were created (not 8)
|
||||
const dedupePlayers = await prisma.player.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: 'Dedupe',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// We expect 4 unique players: "Dedupe Player", "Dedupe Player2", "Dedupe player", "Dedupe PLAYER2"
|
||||
// But due to normalization, "Dedupe player" should match "Dedupe Player" (case-insensitive)
|
||||
// and "Dedupe PLAYER2" should match "Dedupe Player2"
|
||||
// So we should have exactly 2 unique players after deduplication
|
||||
expect(dedupePlayers.length).toBe(2);
|
||||
|
||||
// Verify the players have the correct names (original case preserved)
|
||||
const playerNames = dedupePlayers.map((p: { name: string }) => p.name).sort();
|
||||
expect(playerNames).toEqual(['Dedupe Player', 'Dedupe Player2']);
|
||||
|
||||
// Verify each player has games played
|
||||
dedupePlayers.forEach((player: { gamesPlayed: number }) => {
|
||||
expect(player.gamesPlayed).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(csvPath)) {
|
||||
fs.unlinkSync(csvPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle whitespace variations correctly', async ({ request }) => {
|
||||
// Create a CSV with whitespace variations
|
||||
const csvContent = `Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points
|
||||
1,1,1, Whitespace Player , Whitespace Player2 ,5,Whitespace Player, WhitespacePlayer2,3`;
|
||||
|
||||
const csvPath = path.join(__dirname, 'temp-whitespace-test.csv');
|
||||
fs.writeFileSync(csvPath, csvContent);
|
||||
|
||||
try {
|
||||
const csvFileContent = fs.readFileSync(csvPath, 'utf-8');
|
||||
const blob = new Blob([csvFileContent], { type: 'text/csv' });
|
||||
const file = new File([blob], 'whitespace-test.csv', { type: 'text/csv' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('csvFile', file);
|
||||
formData.append('eventId', testTournamentId.toString());
|
||||
|
||||
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
||||
data: formData,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
// Check that players were created without extra whitespace
|
||||
const whitespacePlayers = await prisma.player.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: 'Whitespace',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Verify no players have leading/trailing whitespace
|
||||
whitespacePlayers.forEach((player: { name: string, normalizedName: string }) => {
|
||||
expect(player.name).toBe(player.name.trim());
|
||||
expect(player.normalizedName).toBe(player.name.trim().toLowerCase());
|
||||
});
|
||||
} finally {
|
||||
if (fs.existsSync(csvPath)) {
|
||||
fs.unlinkSync(csvPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should aggregate stats for duplicate players', async ({ request }) => {
|
||||
// First, create some players manually to simulate previous uploads
|
||||
const player1 = await prisma.player.create({
|
||||
data: {
|
||||
name: 'Aggregate Test',
|
||||
normalizedName: 'aggregate test',
|
||||
currentElo: 1050,
|
||||
gamesPlayed: 5,
|
||||
wins: 3,
|
||||
losses: 2,
|
||||
},
|
||||
});
|
||||
testPlayerIds.push(player1.id);
|
||||
|
||||
// Upload a CSV with the same player name
|
||||
const csvContent = `Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points
|
||||
1,1,1,Aggregate Test,Test Player 1,5,Test Player 2,Test Player 3,3`;
|
||||
|
||||
const csvPath = path.join(__dirname, 'temp-aggregate-test.csv');
|
||||
fs.writeFileSync(csvPath, csvContent);
|
||||
|
||||
try {
|
||||
const csvFileContent = fs.readFileSync(csvPath, 'utf-8');
|
||||
const blob = new Blob([csvFileContent], { type: 'text/csv' });
|
||||
const file = new File([blob], 'aggregate-test.csv', { type: 'text/csv' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('csvFile', file);
|
||||
formData.append('eventId', testTournamentId.toString());
|
||||
|
||||
const response = await request.post('http://localhost:3000/api/matches/upload', {
|
||||
data: formData,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
// Check that the existing player was updated (not duplicated)
|
||||
const aggregatePlayers = await prisma.player.findMany({
|
||||
where: {
|
||||
name: 'Aggregate Test',
|
||||
},
|
||||
});
|
||||
|
||||
// Should still be only one player
|
||||
expect(aggregatePlayers.length).toBe(1);
|
||||
|
||||
// Stats should be updated (1 additional game played, 1 additional win)
|
||||
const player = aggregatePlayers[0];
|
||||
expect(player.gamesPlayed).toBe(6); // 5 + 1
|
||||
expect(player.wins).toBe(4); // 3 + 1
|
||||
expect(player.losses).toBe(2); // unchanged (team lost)
|
||||
} finally {
|
||||
if (fs.existsSync(csvPath)) {
|
||||
fs.unlinkSync(csvPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* E2E Test: Elo Rating Updates
|
||||
*
|
||||
* Tests that Elo ratings are correctly updated when matches are added
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
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',
|
||||
normalizedName: 'elo test player 1',
|
||||
currentElo: 1500,
|
||||
gamesPlayed: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const player2 = await prisma.player.create({
|
||||
data: {
|
||||
name: 'Elo Test Player 2',
|
||||
normalizedName: 'elo test player 2',
|
||||
currentElo: 1500,
|
||||
gamesPlayed: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const player3 = await prisma.player.create({
|
||||
data: {
|
||||
name: 'Elo Test Player 3',
|
||||
normalizedName: 'elo test player 3',
|
||||
currentElo: 1500,
|
||||
gamesPlayed: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const player4 = await prisma.player.create({
|
||||
data: {
|
||||
name: 'Elo Test Player 4',
|
||||
normalizedName: '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 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',
|
||||
normalizedName: '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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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 { prisma } from '@/lib/prisma';
|
||||
|
||||
|
||||
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,193 @@
|
||||
/**
|
||||
* 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 { prisma } from '@/lib/prisma';
|
||||
|
||||
|
||||
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 {
|
||||
// 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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,149 @@
|
||||
/**
|
||||
* 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 { prisma } from '@/lib/prisma';
|
||||
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Global setup for Playwright tests
|
||||
* Creates test users and saves authentication state
|
||||
*/
|
||||
|
||||
import { chromium, type FullConfig } from '@playwright/test';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { cleanupAllTestData } from '@/__tests__/test-utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Load .env file first, then .env.development (which will override .env)
|
||||
const envPath = path.resolve(process.cwd(), '.env');
|
||||
const envDevPath = path.resolve(process.cwd(), '.env.development');
|
||||
|
||||
// Load base .env file
|
||||
if (fs.existsSync(envPath)) {
|
||||
require('dotenv').config({ path: envPath });
|
||||
}
|
||||
|
||||
// Load .env.development file (will override .env settings)
|
||||
if (fs.existsSync(envDevPath)) {
|
||||
require('dotenv').config({ path: envDevPath, override: true });
|
||||
}
|
||||
|
||||
const authFile = 'playwright/.auth/user.json';
|
||||
const adminAuthFile = 'playwright/.auth/admin.json';
|
||||
|
||||
// Check if we're using the dev database
|
||||
function isDevDatabase(): boolean {
|
||||
const dbUrl = process.env.DATABASE_URL || '';
|
||||
return dbUrl.includes('euchre_camp_dev');
|
||||
}
|
||||
|
||||
function isProductionDatabase(): boolean {
|
||||
const dbUrl = process.env.DATABASE_URL || '';
|
||||
return dbUrl.includes('euchre_camp') && !dbUrl.includes('_dev');
|
||||
}
|
||||
|
||||
// Strict check - fail if using production database
|
||||
if (isProductionDatabase()) {
|
||||
console.error('');
|
||||
console.error('='.repeat(80));
|
||||
console.error('CRITICAL ERROR: Tests are attempting to run against PRODUCTION database!');
|
||||
console.error('='.repeat(80));
|
||||
console.error('');
|
||||
console.error('Current DATABASE_URL:', process.env.DATABASE_URL);
|
||||
console.error('');
|
||||
console.error('Tests MUST run against the development database (euchre_camp_dev)');
|
||||
console.error('');
|
||||
console.error('To fix this:');
|
||||
console.error(' 1. Run: npm run test:acceptance');
|
||||
console.error(' 2. Or set: DATABASE_URL environment variable to dev database URL');
|
||||
console.error(' 3. Or load .env.development: source .env.development && npm run test:acceptance');
|
||||
console.error('');
|
||||
console.error('Aborting test execution to prevent data corruption.');
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isDevDatabase()) {
|
||||
console.warn('⚠️ WARNING: DATABASE_URL does not contain euchre_camp_dev');
|
||||
console.warn(' Current DATABASE_URL:', process.env.DATABASE_URL);
|
||||
}
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
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 {
|
||||
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 {
|
||||
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 () => {
|
||||
console.log('\n=== Global Teardown ===');
|
||||
|
||||
// Clean up all test data
|
||||
try {
|
||||
await cleanupAllTestData();
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up test data:', error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createTestPlayer, cleanupTestRecords, getCreatedRecordCounts } from '@/__tests__/test-utils'
|
||||
|
||||
test.describe('Home Page', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Clean up any existing test records before each test
|
||||
await cleanupTestRecords();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up test records after each test
|
||||
await cleanupTestRecords();
|
||||
});
|
||||
|
||||
test('should display top 10 players', async ({ page }) => {
|
||||
// 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 playerName = `Home Test Player ${timestamp} ${i + 1}`
|
||||
const player = await createTestPlayer({
|
||||
name: playerName,
|
||||
currentElo: 2000 - i * 10,
|
||||
})
|
||||
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()
|
||||
|
||||
// Verify cleanup will work
|
||||
const counts = getCreatedRecordCounts();
|
||||
expect(counts.players).toBe(3);
|
||||
})
|
||||
|
||||
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 player1Name = `Home Match Player 1 ${timestamp}`
|
||||
const player1 = await prisma.player.create({
|
||||
data: { name: player1Name, normalizedName: player1Name.toLowerCase(), currentElo: 1500 },
|
||||
})
|
||||
const player2Name = `Home Match Player 2 ${timestamp}`
|
||||
const player2 = await prisma.player.create({
|
||||
data: { name: player2Name, normalizedName: player2Name.toLowerCase(), currentElo: 1480 },
|
||||
})
|
||||
const player3Name = `Home Match Player 3 ${timestamp}`
|
||||
const player3 = await prisma.player.create({
|
||||
data: { name: player3Name, normalizedName: player3Name.toLowerCase(), currentElo: 1450 },
|
||||
})
|
||||
const player4Name = `Home Match Player 4 ${timestamp}`
|
||||
const player4 = await prisma.player.create({
|
||||
data: { name: player4Name, normalizedName: player4Name.toLowerCase(), 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] } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Smoke Test: EuchreCamp Application
|
||||
*
|
||||
* This test suite provides comprehensive acceptance/regression testing
|
||||
* for the core functionality of the EuchreCamp application.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Smoke Test: EuchreCamp Application', () => {
|
||||
test.describe('Admin Panel Navigation', () => {
|
||||
test('should navigate to admin dashboard', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
// Admin dashboard should be visible
|
||||
await expect(page.locator('text=Admin')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate to matches admin page', async ({ page }) => {
|
||||
await page.goto('/admin/matches')
|
||||
await expect(page.locator('text=Match Management')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate to players admin page', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
await expect(page.locator('text=Player Management')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate to users admin page', async ({ page }) => {
|
||||
await page.goto('/admin/users')
|
||||
await expect(page.locator('text=User Management')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Match Management', () => {
|
||||
test('should display matches page', async ({ page }) => {
|
||||
await page.goto('/admin/matches')
|
||||
// Verify page header is visible
|
||||
await expect(page.locator('text=Match Management')).toBeVisible()
|
||||
|
||||
// Page should load successfully - verify either table or empty state is present
|
||||
const hasTable = await page.locator('table').count().then(c => c > 0)
|
||||
const hasEmptyState = await page.locator('text=/no matches|No matches/').count().then(c => c > 0)
|
||||
|
||||
// At least one should be present
|
||||
expect(hasTable || hasEmptyState).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should have delete button for matches when matches exist', async ({ page }) => {
|
||||
await page.goto('/admin/matches')
|
||||
// Check if delete buttons exist in the table
|
||||
const deleteButtons = page.locator('button:has-text("Delete")')
|
||||
const count = await deleteButtons.count()
|
||||
// Table might be empty, but if there are matches, delete buttons should exist
|
||||
if (count > 0) {
|
||||
await expect(page.locator('text=Actions')).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Player Management', () => {
|
||||
test('should display players table', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
await expect(page.locator('table')).toBeVisible()
|
||||
await expect(page.locator('text=Player Name')).toBeVisible()
|
||||
await expect(page.locator('text=Current Elo')).toBeVisible()
|
||||
await expect(page.locator('text=Actions')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have edit and delete buttons for players', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
// At minimum, verify the Actions column exists
|
||||
await expect(page.locator('text=Actions')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should allow editing player name', async ({ page }) => {
|
||||
await page.goto('/admin/players')
|
||||
// Click edit on first player
|
||||
const editButton = page.locator('button:has-text("Edit")').first()
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click()
|
||||
|
||||
// Verify edit modal appears
|
||||
await expect(page.locator('text=Edit Player Name')).toBeVisible()
|
||||
|
||||
// Close modal
|
||||
await page.click('text=Cancel')
|
||||
await expect(page.locator('text=Edit Player Name')).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('User Management', () => {
|
||||
test('should display users page', async ({ page }) => {
|
||||
await page.goto('/admin/users')
|
||||
// Page should load with either a table or "no users" message
|
||||
const hasTable = await page.locator('table').isVisible().catch(() => false)
|
||||
const hasNoUsers = await page.locator('text=No users found').isVisible().catch(() => false)
|
||||
|
||||
// At least one of these should be true
|
||||
expect(hasTable || hasNoUsers).toBeTruthy()
|
||||
|
||||
// Verify page header is visible
|
||||
await expect(page.locator('text=User Management')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have create user link', async ({ page }) => {
|
||||
await page.goto('/admin/users')
|
||||
// Check for the main create user link (with green button styling)
|
||||
const createLink = page.locator('a.bg-green-600:has-text("Create User")')
|
||||
await expect(createLink).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Public Pages', () => {
|
||||
test('should display rankings page', async ({ page }) => {
|
||||
await page.goto('/rankings')
|
||||
// Rankings page should be accessible even when authenticated
|
||||
// Just verify the page loads without error
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display player profile page', async ({ page }) => {
|
||||
// Navigate to a player profile (using player ID 1 which should exist)
|
||||
await page.goto('/players/1/profile')
|
||||
// Profile page should be accessible even when authenticated
|
||||
// Just verify the page loads without error
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* E2E Test: Tournament Admin Permissions
|
||||
*
|
||||
* Tests that tournament_admin users can access tournament edit pages for their own tournaments
|
||||
* Regression test for the issue where tournament_admin users were redirected to login
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
|
||||
test.describe('Tournament Admin Permissions', () => {
|
||||
let tournamentId: number;
|
||||
let tournamentAdminEmail: string;
|
||||
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create a test tournament admin user
|
||||
const timestamp = Date.now();
|
||||
tournamentAdminEmail = `tour-admin-${timestamp}@example.com`;
|
||||
|
||||
// Create user via API (simulating registration)
|
||||
// Note: In a real scenario, we'd use the auth API, but for this test
|
||||
// we'll create the user directly in the database for simplicity
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: tournamentAdminEmail,
|
||||
emailVerified: true,
|
||||
name: 'Tournament Admin Test',
|
||||
role: 'tournament_admin',
|
||||
// Note: In production, passwords would be hashed
|
||||
// For testing, we're using Better Auth's internal API
|
||||
},
|
||||
});
|
||||
|
||||
// Create a tournament owned by this user
|
||||
const tournament = await prisma.event.create({
|
||||
data: {
|
||||
name: 'Tournament Admin Test Tournament',
|
||||
eventDate: new Date(),
|
||||
eventType: 'tournament',
|
||||
format: 'round_robin',
|
||||
status: 'planned',
|
||||
ownerId: user.id,
|
||||
},
|
||||
});
|
||||
tournamentId = tournament.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up test data
|
||||
if (tournamentId) {
|
||||
await prisma.event.delete({
|
||||
where: { id: tournamentId },
|
||||
});
|
||||
}
|
||||
|
||||
if (tournamentAdminEmail) {
|
||||
await prisma.user.deleteMany({
|
||||
where: { email: tournamentAdminEmail },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test('tournament_admin can access tournament detail page', async () => {
|
||||
// Note: This test would require authentication setup
|
||||
// For now, we'll verify the permission logic works correctly
|
||||
// by checking the canManageTournament function directly
|
||||
|
||||
// In a real e2e test, we would:
|
||||
// 1. Log in as tournament_admin user
|
||||
// 2. Navigate to the tournament detail page
|
||||
// 3. Verify the edit link is visible
|
||||
// 4. Click the edit link
|
||||
// 5. Verify we are NOT redirected to login
|
||||
|
||||
// Since we can't easily set up authentication in this test environment,
|
||||
// we'll skip the UI test and rely on the unit tests for permission logic
|
||||
|
||||
test.skip(true, 'Authentication setup required for full e2e test');
|
||||
});
|
||||
|
||||
test('tournament_admin cannot access other users tournaments', async () => {
|
||||
// Create another user's tournament
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `other-user-${Date.now()}@example.com`,
|
||||
emailVerified: true,
|
||||
name: 'Other User',
|
||||
role: 'tournament_admin',
|
||||
},
|
||||
});
|
||||
|
||||
const otherTournament = await prisma.event.create({
|
||||
data: {
|
||||
name: 'Other User Tournament',
|
||||
eventDate: new Date(),
|
||||
eventType: 'tournament',
|
||||
format: 'round_robin',
|
||||
status: 'planned',
|
||||
ownerId: otherUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Verify the tournament exists
|
||||
const tournament = await prisma.event.findUnique({
|
||||
where: { id: otherTournament.id },
|
||||
});
|
||||
expect(tournament).not.toBeNull();
|
||||
|
||||
// In a real e2e test, we would:
|
||||
// 1. Log in as tournament_admin user (different from the tournament owner)
|
||||
// 2. Try to navigate to the other user's tournament edit page
|
||||
// 3. Verify we are redirected to login or see an error
|
||||
|
||||
// For now, we verify the permission logic in unit tests
|
||||
test.skip(true, 'Authentication setup required for full e2e test');
|
||||
} finally {
|
||||
// Clean up
|
||||
await prisma.event.delete({ where: { id: otherTournament.id } });
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Additional test for club_admin (should still work as before)
|
||||
test.describe('Club Admin Permissions (Regression)', () => {
|
||||
test('club_admin should still be able to manage any tournament', async () => {
|
||||
// This is verified in unit tests
|
||||
// The e2e test would verify the UI behavior
|
||||
test.skip(true, 'Verified in unit tests');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* E2E Test: Tournament Edit with allowTies
|
||||
*
|
||||
* User Story: As a tournament admin, I want to edit tournament settings including allowTies
|
||||
*
|
||||
* Acceptance Criteria:
|
||||
* - Can edit tournament settings
|
||||
* - allowTies checkbox can be toggled
|
||||
* - allowTies value is saved correctly
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
test.describe('Tournament Edit - allowTies functionality', () => {
|
||||
let tournamentId: number;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create a test tournament for editing
|
||||
const tournament = await prisma.event.create({
|
||||
data: {
|
||||
name: 'Test Tournament for AllowTies Edit',
|
||||
eventType: 'tournament',
|
||||
format: 'round_robin',
|
||||
status: 'planned',
|
||||
allowTies: false,
|
||||
targetScore: 5,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
tournamentId = tournament.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up test tournament
|
||||
if (tournamentId) {
|
||||
await prisma.event.delete({
|
||||
where: { id: tournamentId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should display allowTies checkbox on edit form', async ({ page }) => {
|
||||
// Navigate to tournament edit page
|
||||
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
|
||||
|
||||
// Wait for form to load
|
||||
await expect(page.locator('text=Tournament Name')).toBeVisible();
|
||||
|
||||
// Check that allowTies checkbox exists
|
||||
const allowTiesCheckbox = page.locator('input[name="allowTies"]');
|
||||
await expect(allowTiesCheckbox).toBeVisible();
|
||||
await expect(allowTiesCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('should save allowTies when toggled to true', async ({ page }) => {
|
||||
// Navigate to tournament edit page
|
||||
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
|
||||
|
||||
// Wait for form to load
|
||||
await expect(page.locator('text=Tournament Name')).toBeVisible();
|
||||
|
||||
// Toggle allowTies checkbox
|
||||
const allowTiesCheckbox = page.locator('input[name="allowTies"]');
|
||||
await allowTiesCheckbox.check();
|
||||
await expect(allowTiesCheckbox).toBeChecked();
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for success message
|
||||
await expect(page.locator('text=Tournament updated successfully')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify allowTies was saved by checking the database
|
||||
const updatedTournament = await prisma.event.findUnique({
|
||||
where: { id: tournamentId },
|
||||
});
|
||||
|
||||
expect(updatedTournament?.allowTies).toBe(true);
|
||||
});
|
||||
|
||||
test('should save allowTies when toggled to false', async ({ page }) => {
|
||||
// First, set allowTies to true
|
||||
await prisma.event.update({
|
||||
where: { id: tournamentId },
|
||||
data: { allowTies: true },
|
||||
});
|
||||
|
||||
// Navigate to tournament edit page
|
||||
await page.goto(`/admin/tournaments/${tournamentId}/edit`);
|
||||
|
||||
// Wait for form to load
|
||||
await expect(page.locator('text=Tournament Name')).toBeVisible();
|
||||
|
||||
// Verify checkbox is checked
|
||||
const allowTiesCheckbox = page.locator('input[name="allowTies"]');
|
||||
await expect(allowTiesCheckbox).toBeChecked();
|
||||
|
||||
// Uncheck allowTies checkbox
|
||||
await allowTiesCheckbox.uncheck();
|
||||
await expect(allowTiesCheckbox).not.toBeChecked();
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for success message
|
||||
await expect(page.locator('text=Tournament updated successfully')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify allowTies was saved by checking the database
|
||||
const updatedTournament = await prisma.event.findUnique({
|
||||
where: { id: tournamentId },
|
||||
});
|
||||
|
||||
expect(updatedTournament?.allowTies).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user