feat: Add PlayerManagement GM interface and clean repository

- Add comprehensive PlayerManagement component for GMs
- Add all GM API endpoints for player CRUD operations
- Add comprehensive test suite with 14 tests
- Clean up .gitignore to exclude logs, backups, core dumps
- Update README with setup instructions and documentation
- Add setup.sh script for easy project initialization

This provides a complete GM interface for managing players without
the large files that were blocking the previous push.
This commit is contained in:
2025-09-05 11:05:53 +02:00
parent c8c7e3936e
commit a5a6b963fa
7 changed files with 1501 additions and 54 deletions

52
.gitignore vendored
View File

@@ -23,27 +23,45 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Deathwatch-Core-Rulebook.pdf
data/journal/*
data/Deathwatch - Core Rulebook.pdf
data/Deathwatch - Ark of Lost Souls.pdf
data/Deathwatch Living Errata v1-1.pdf
.gitignore
data/Deathwatch - First Founding.pdf
data/Deathwatch - Honour the Chapter.pdf
data/Deathwatch - Mark of the Xenos.pdf
data/Deathwatch - Oblivions Edge.pdf
data/Deathwatch - Final Sanction.pdf
data/Deathwatch - The Outer Reach.pdf
data/Deathwatch - The Achilus Assault.pdf
# Ignore all PDF files (prevent including copyrighted book PDFs)
*.pdf
# Database and server log files
# Log files
*.log
*.log.*
backend.log
server.log
database/backend.log
database/server.log
database/sqlite/*.db-shm
database/sqlite/*.db-wal
# Database files (should be created fresh on deployment)
*.db
*.db-*
*.sqlite
*.sqlite3
sqlite/
sqlite-db.db
# Backup and temporary files
*backup*
*temp*
backups/
database/backups/
# Build artifacts with backup names
*.backup.*
*-backup-*
# PM2 logs and runtime files
.pm2/
*.pid
# SQLite WAL files
*.db-shm
*.db-wal
# Core dumps and crash files
core
core.*
*.core
*.dump

View File

@@ -1,56 +1,82 @@
<<<<<<< HEAD
# Deathwatch Roller
## Project Description
Deathwatch Roller is a React-based application designed to assist players and Game Masters in managing their sessions for the Deathwatch tabletop RPG. It includes features like player management, requisition shop, and session tracking.
Deathwatch Roller is a React-based application designed to assist players and Game Masters in managing their sessions for the Deathwatch tabletop RPG. It includes features like player management, requisition shop, session tracking, and comprehensive GM tools.
## Features
- Player management
- Requisition shop for items
- Session tracking
- **Player Management**: Full CRUD operations for player accounts (GM only)
- **Requisition Shop**: Browse and purchase items with RP
- **Session Tracking**: Track game sessions and player progress
- **GM Kit**: Comprehensive Game Master tools and utilities
- **Bestiary**: Monster and enemy reference
- **Rules Database**: Searchable rules and game mechanics
## Setup Instructions
## Quick Start
### Prerequisites
- Node.js (v18+ recommended)
- npm or yarn
### Installation
1. Clone the repository:
```bash
git clone https://github.com/your-username/deathwatch-roller.git
git clone https://github.com/alexpolo1/dwroller.git
cd dwroller
```
2. Navigate to the project directory:
```bash
cd deathwatch-roller
```
3. Install dependencies:
2. Install dependencies:
```bash
npm install
```
4. Start the development server:
3. Start the development server:
```bash
npm start
```
4. Start the backend server:
```bash
npm run server
```
The application will be available at `http://localhost:3000` with the API at `http://localhost:5000`.
### Production Deployment
For production deployment with PM2:
```bash
npm run build
npm run pm2:start
```
## Clean Repository
This repository is configured to exclude:
- Log files (`*.log`)
- Database files (`*.db`, `*.sqlite`)
- Backup files (`*backup*`, `backups/`)
- Temporary files
- PDF files (copyrighted content)
When you clone this repository, the database will be automatically created with the necessary tables on first run.
## GM Features
The application includes a comprehensive Player Management interface available only to Game Masters:
- Create and delete player accounts
- Manage requisition points, experience, and renown
- Reset player passwords
- Bulk operations for XP/RP management
## API Endpoints
- `GET /api/players` - Get all players (requires session)
- `POST /api/players/gm/*` - GM-only endpoints (requires x-gm-secret header)
- `GET /api/shop` - Get shop inventory
- `GET /api/bestiary` - Get bestiary data
## Contribution Guidelines
- Fork the repository.
- Create a new branch for your feature or bug fix.
- Submit a pull request with a detailed description of your changes.
- Fork the repository
- Create a new branch for your feature or bug fix
- Follow the existing code style and patterns
- Add tests for new functionality
- Submit a pull request with a detailed description
## License
This project is licensed under the MIT License. See the LICENSE file for details.
## Learn More
- [React Documentation](https://reactjs.org/)
- [Create React App Documentation](https://create-react-app.dev/)
## OpenAI sanitizer
This project includes a helper to sanitize OCR/extracted rule blocks before importing into the database.
Usage:
1. Add your OpenAI API key to a local `.env` file in the project root (DO NOT commit this file):
```
OPENAI_API_KEY=sk-REPLACE
```
2. Run the sanitizer (it reads `database/backups/extracted-blocks.json` by default):
```
npm run sanitize:openai
```

View File

@@ -409,4 +409,268 @@ router.post('/:name/avatar', requireSession, async (req, res) => {
}
});
// GM ENDPOINTS - bypassing session validation with GM secret
function gmBypass(req, res, next) {
const gmSecret = req.headers['x-gm-secret'];
if (gmSecret === 'bongo') {
logToFile('SESSION: GM bypass accepted', req.method, req.url);
return next();
}
logToFile('SESSION: GM bypass rejected - invalid secret', req.method, req.url);
return res.status(401).json({ error: 'GM access denied' });
}
// Add/update player (GM only)
router.post('/gm/add-or-update', gmBypass, (req, res) => {
try {
const { name, rp, pw } = req.body;
if (!name) {
return res.status(400).json({ error: 'Player name is required' });
}
// Check if player exists
const existing = playerHelpers.getByName(name);
if (existing) {
// Update existing player
const updates = { ...existing };
if (rp !== undefined) updates.tabInfo = { ...updates.tabInfo, rp: parseInt(rp) };
if (pw) updates.pwHash = require('bcrypt').hashSync(pw, 10);
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(name, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player' });
logToFile('GM: Updated player', name);
return res.json({ success: true, message: `Updated player ${name}` });
} else {
// Create new player
const defaultRP = rp !== undefined ? parseInt(rp) : 50;
const password = pw || '1234';
const newPlayer = {
name,
tabInfo: {
rp: defaultRP,
renown: 'None',
xp: 0,
xpSpent: 0,
charName: `Brother ${name.charAt(0).toUpperCase() + name.slice(1)}`
},
rollerInfo: {},
shopInfo: {},
pwHash: require('bcrypt').hashSync(password, 10),
pw: ''
};
const { valid, errors, normalized } = validatePlayer(newPlayer);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const saved = playerHelpers.create(normalized);
if (!saved) return res.status(500).json({ error: 'Failed to create player' });
logToFile('GM: Created player', name);
return res.json({ success: true, message: `Created player ${name}` });
}
} catch (error) {
logToFile('GM: Add/update player failed', error);
res.status(500).json({ error: 'Failed to add/update player' });
}
});
// Set RP (GM only)
router.post('/gm/set-rp', gmBypass, (req, res) => {
try {
const { playerName, requisitionPoints } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, rp: parseInt(requisitionPoints) }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player RP' });
logToFile('GM: Set RP for', playerName, 'to', requisitionPoints);
res.json({ success: true, message: `Set RP for ${playerName} to ${requisitionPoints}` });
} catch (error) {
logToFile('GM: Set RP failed', error);
res.status(500).json({ error: 'Failed to set RP' });
}
});
// Set XP (GM only)
router.post('/gm/set-xp', gmBypass, (req, res) => {
try {
const { playerName, xp } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, xp: parseInt(xp) }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player XP' });
logToFile('GM: Set XP for', playerName, 'to', xp);
res.json({ success: true, message: `Set XP for ${playerName} to ${xp}` });
} catch (error) {
logToFile('GM: Set XP failed', error);
res.status(500).json({ error: 'Failed to set XP' });
}
});
// Set XP Spent (GM only)
router.post('/gm/set-xp-spent', gmBypass, (req, res) => {
try {
const { playerName, xpSpent } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, xpSpent: parseInt(xpSpent) }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player XP Spent' });
logToFile('GM: Set XP Spent for', playerName, 'to', xpSpent);
res.json({ success: true, message: `Set XP Spent for ${playerName} to ${xpSpent}` });
} catch (error) {
logToFile('GM: Set XP Spent failed', error);
res.status(500).json({ error: 'Failed to set XP Spent' });
}
});
// Set Renown (GM only)
router.post('/gm/set-renown', gmBypass, (req, res) => {
try {
const { playerName, renown } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, renown }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player renown' });
logToFile('GM: Set renown for', playerName, 'to', renown);
res.json({ success: true, message: `Set renown for ${playerName} to ${renown}` });
} catch (error) {
logToFile('GM: Set renown failed', error);
res.status(500).json({ error: 'Failed to set renown' });
}
});
// Reset password (GM only)
router.post('/gm/reset-password', gmBypass, (req, res) => {
try {
const { playerName, newPassword } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const password = newPassword || '1234';
const updates = {
...player,
pwHash: require('bcrypt').hashSync(password, 10),
pw: ''
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to reset password' });
logToFile('GM: Reset password for', playerName);
res.json({ success: true, message: `Reset password for ${playerName}` });
} catch (error) {
logToFile('GM: Reset password failed', error);
res.status(500).json({ error: 'Failed to reset password' });
}
});
// Delete player (GM only)
router.delete('/gm/delete/:playerName', gmBypass, (req, res) => {
try {
const { playerName } = req.params;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const ok = playerHelpers.delete(playerName);
if (!ok) return res.status(500).json({ error: 'Failed to delete player' });
logToFile('GM: Deleted player', playerName);
res.json({ success: true, message: `Deleted player ${playerName}` });
} catch (error) {
logToFile('GM: Delete player failed', error);
res.status(500).json({ error: 'Failed to delete player' });
}
});
module.exports = router;

43
setup.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Deathwatch Roller Setup Script
echo "🎲 Setting up Deathwatch Roller..."
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js (v18+) first."
exit 1
fi
echo "✅ Node.js $(node --version) found"
# Install dependencies
echo "📦 Installing dependencies..."
npm install
# Check if PM2 is installed globally
if ! command -v pm2 &> /dev/null; then
echo "⚠️ PM2 not found. Installing PM2 for production deployment..."
npm install -g pm2
fi
# Create necessary directories
mkdir -p database/sqlite
mkdir -p public/avatars
# Initialize database (it will be created automatically on first server start)
echo "🗄️ Database will be initialized on first server start"
echo ""
echo "🎉 Setup complete!"
echo ""
echo "To start development:"
echo " npm start # Start React dev server"
echo " npm run server # Start backend server (in another terminal)"
echo ""
echo "To start production:"
echo " npm run build # Build for production"
echo " npm run pm2:start # Start with PM2"
echo ""
echo "Access the application at: http://localhost:3000"
echo "Backend API available at: http://localhost:5000"

View File

@@ -5,6 +5,7 @@ import PlayerTab from './components/PlayerTab';
import RulesTab from './components/RulesTab';
import BestiaryTab from './components/BestiaryTab';
import GMKit from './components/GMKit';
import PlayerManagement from './components/PlayerManagement';
import { useState, useEffect } from 'react';
import axios from 'axios';
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
@@ -266,6 +267,12 @@ function App() {
>
Bestiary
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='players' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'players' }); setTab('players')}}
>
Player Management
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='gmkit' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'gmkit' }); setTab('gmkit')}}
@@ -311,7 +318,7 @@ function App() {
</div>
)}
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
authedPlayer={authedPlayer}
sessionId={sessionId}
/>}

View File

@@ -0,0 +1,656 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero'];
export default function PlayerManagement({ authedPlayer, sessionId }) {
const [players, setPlayers] = useState([]);
const [saveMsg, setSaveMsg] = useState('');
// Helper to build request headers
function buildHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json' };
if (sessionId) headers['x-session-id'] = sessionId;
if (authedPlayer === 'gm') headers['x-gm-secret'] = 'bongo';
return { ...headers, ...extra };
}
function flash(msg) {
setSaveMsg(msg);
setTimeout(() => setSaveMsg(''), 3000);
}
// Fetch players from the database
const fetchPlayers = useCallback(async () => {
try {
const res = await axios.get('/api/players', { headers: buildHeaders() });
setPlayers(res.data || []);
} catch (err) {
console.error('Failed to fetch players:', err);
flash('Failed to fetch players');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, authedPlayer]);
useEffect(() => {
fetchPlayers();
}, [fetchPlayers]);
if (authedPlayer !== 'gm') return (
<div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30">
<h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2>
<p className="text-red-200">Player Management is only accessible to Game Masters. Please log in as a GM.</p>
</div>
);
// GM action handlers
async function gmSetXP(name, xp) {
console.log('PlayerManagement: gmSetXP', { name, xp });
try {
const res = await axios.post('/api/players/gm/set-xp',
{ playerName: name, xp: parseInt(xp) },
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmSetXP response', res.status, res.data);
flash(`Set XP for ${name} to ${xp}`);
// Refresh players list
await fetchPlayers();
} catch (e) {
console.error('PlayerManagement: gmSetXP failed', e.response?.data || e.message);
flash(`Failed to set XP for ${name}: ${e.response?.data?.message || e.message}`);
}
}
async function gmSetXPSpent(name, xpSpent) {
console.log('PlayerManagement: gmSetXPSpent', { name, xpSpent });
try {
const res = await axios.post('/api/players/gm/set-xp-spent',
{ playerName: name, xpSpent: parseInt(xpSpent) },
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmSetXPSpent response', res.status, res.data);
flash(`Set XP Spent for ${name} to ${xpSpent}`);
// Refresh players list
await fetchPlayers();
} catch (e) {
console.error('PlayerManagement: gmSetXPSpent failed', e.response?.data || e.message);
flash(`Failed to set XP Spent for ${name}: ${e.response?.data?.message || e.message}`);
}
}
async function gmAddOrUpdatePlayer(name, rp, pw) {
console.log('PlayerManagement: gmAddOrUpdatePlayer', { name, rp, pwProvided: !!pw });
try {
const resPost = await axios.post('/api/players/gm/add-or-update',
{ name, requisitionPoints: parseInt(rp), password: pw || '1234' },
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmAddOrUpdatePlayer POST response', resPost.status, resPost.data);
flash(`Added/Updated player ${name} with ${rp} RP`);
// Refresh players list
await fetchPlayers();
} catch (e) {
console.error('PlayerManagement: gmAddOrUpdatePlayer failed', e.response?.data || e.message);
flash(`Failed to add/update player ${name}: ${e.response?.data?.message || e.message}`);
}
}
async function gmSetRP(name, rp) {
console.log('PlayerManagement: gmSetRP', { name, rp });
try {
const url = `/api/players/gm/set-rp`;
const payload = { playerName: name, requisitionPoints: parseInt(rp) };
const config = { headers: buildHeaders() };
console.log('PlayerManagement: Making request to', url, 'with payload', payload, 'and config', config);
const res = await axios.post(url, payload, config);
console.log('PlayerManagement: gmSetRP response', res.status, res.data);
flash(`Set RP for ${name} to ${rp}`);
// Refresh players list to show updated RP
await fetchPlayers();
} catch (e) {
console.error('PlayerManagement: gmSetRP failed', e.response?.data || e.message);
flash(`Failed to set RP for ${name}: ${e.response?.data?.message || e.message}`);
}
}
async function gmSetRenown(name, renown) {
console.log('PlayerManagement: gmSetRenown', { name, renown });
try {
const res = await axios.post('/api/players/gm/set-renown',
{ playerName: name, renown },
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmSetRenown response', res.status, res.data);
flash(`Set Renown for ${name} to ${renown}`);
// Refresh players list
await fetchPlayers();
} catch (e) {
console.error('PlayerManagement: gmSetRenown failed', e.response?.data || e.message);
flash(`Failed to set Renown for ${name}: ${e.response?.data?.message || e.message}`);
}
}
async function gmResetPlayerPw(name, pw) {
console.log('PlayerManagement: gmResetPlayerPw', { name, pwProvided: !!pw });
try {
const res = await axios.post('/api/players/gm/reset-password',
{ playerName: name, newPassword: pw || '1234' },
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmResetPlayerPw response', res.status, res.data);
flash(`Reset password for ${name}`);
} catch (e) {
console.error('PlayerManagement: gmResetPlayerPw failed', e.response?.data || e.message);
flash(`Failed to reset password for ${name}: ${e.response?.data?.message || e.message}`);
}
}
async function gmDeletePlayer(name) {
if (!window.confirm(`Are you sure you want to delete player ${name}? This cannot be undone.`)) {
return;
}
try {
const res = await axios.delete(`/api/players/gm/delete/${encodeURIComponent(name)}`,
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmDeletePlayer response', res.status, res.data);
flash(`Deleted player ${name}`);
// Refresh players list
await fetchPlayers();
} catch (e) {
console.error('PlayerManagement: gmDeletePlayer failed', e.response?.data || e.message);
flash(`Failed to delete player ${name}: ${e.response?.data?.message || e.message}`);
}
}
// Component for adding new players
function GmAddPlayer({ onAdd }) {
const [name, setName] = useState('');
const [rp, setRp] = useState('50');
const [pw, setPw] = useState('');
return (
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
<h4 className="text-lg font-medium text-white mb-3">Add New Player</h4>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<input
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
type="text"
placeholder="Player Name"
value={name}
onChange={e => setName(e.target.value)}
/>
<input
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
type="number"
placeholder="Requisition Points"
value={rp}
onChange={e => setRp(e.target.value)}
/>
<input
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
type="password"
placeholder="Password (default: 1234)"
value={pw}
onChange={e => setPw(e.target.value)}
/>
<button
className="rounded bg-green-600 hover:bg-green-500 px-4 py-2 text-white text-sm font-medium transition-colors"
onClick={() => {
if (name.trim()) {
onAdd(name.trim(), rp, pw || '1234');
setName('');
setRp('50');
setPw('');
}
}}
>
Add Player
</button>
</div>
</div>
);
}
// Component for setting RP
function GmSetRP({ name, currentRP, onSet }) {
const [rp, setRp] = useState(currentRP?.toString() || '0');
// Update state when currentRP prop changes
useEffect(() => {
setRp(currentRP?.toString() || '0');
}, [currentRP]);
return (
<div className="flex items-center gap-2 w-full">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
type="number"
value={rp}
onChange={e => setRp(e.target.value)}
/>
<button
className="rounded bg-blue-600 hover:bg-blue-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
onClick={() => onSet(name, rp)}
>
Set
</button>
</div>
);
}
// Component for setting XP
function GmSetXP({ name, currentXP, onSet }) {
const [xp, setXp] = useState(currentXP?.toString() || '0');
// Update state when currentXP prop changes
useEffect(() => {
setXp(currentXP?.toString() || '0');
}, [currentXP]);
return (
<div className="flex items-center gap-2 w-full">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
type="number"
value={xp}
onChange={e => setXp(e.target.value)}
/>
<button
className="rounded bg-purple-600 hover:bg-purple-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
onClick={() => onSet(name, xp)}
>
Set
</button>
</div>
);
}
// Component for setting XP Spent
function GmSetXPSpent({ name, currentXPSpent, onSet }) {
const [xpSpent, setXpSpent] = useState(currentXPSpent?.toString() || '0');
// Update state when currentXPSpent prop changes
useEffect(() => {
setXpSpent(currentXPSpent?.toString() || '0');
}, [currentXPSpent]);
return (
<div className="flex items-center gap-2 w-full">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
type="number"
value={xpSpent}
onChange={e => setXpSpent(e.target.value)}
/>
<button
className="rounded bg-indigo-600 hover:bg-indigo-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
onClick={() => onSet(name, xpSpent)}
>
Set
</button>
</div>
);
}
// Component for setting Renown
function GmSetRenown({ name, currentRenown, onSet }) {
const [renown, setRenown] = useState(currentRenown || 'None');
// Update state when currentRenown prop changes
useEffect(() => {
setRenown(currentRenown || 'None');
}, [currentRenown]);
return (
<div className="flex items-center gap-2 w-full">
<select
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
value={renown}
onChange={e => setRenown(e.target.value)}
>
{RANK_ORDER.map(rank => (
<option key={rank} value={rank} className="bg-slate-800">{rank}</option>
))}
</select>
<button
className="rounded bg-yellow-600 hover:bg-yellow-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
onClick={() => onSet(name, renown)}
>
Set
</button>
</div>
);
}
// Component for resetting password
function GmResetPW({ name, onReset }) {
const [pw, setPw] = useState('');
return (
<div className="flex items-center gap-2 w-full">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white placeholder-white/50 text-sm flex-1 min-w-0"
type="password"
placeholder="New password (default: 1234)"
value={pw}
onChange={e => setPw(e.target.value)}
/>
<button
className="rounded bg-orange-600 hover:bg-orange-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
onClick={() => {
onReset(name, pw || '1234');
setPw('');
}}
>
Reset
</button>
</div>
);
}
// Component for bulk XP giving
function BulkXPGiver({ onGive }) {
const [amount, setAmount] = useState('100');
return (
<div className="flex items-center gap-2">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
<button
className="text-xs px-3 py-1 rounded bg-purple-600/80 hover:bg-purple-600 text-white transition-colors whitespace-nowrap"
onClick={() => {
if (window.confirm(`Give ${amount} XP to all ${players.length} players?`)) {
onGive(parseInt(amount));
flash(`Gave ${amount} XP to all players`);
}
}}
>
Give XP to All
</button>
</div>
);
}
// Component for bulk XP setting
function BulkXPSetter({ onSet }) {
const [amount, setAmount] = useState('1000');
return (
<div className="flex items-center gap-2">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
<button
className="text-xs px-3 py-1 rounded bg-purple-500/80 hover:bg-purple-500 text-white transition-colors whitespace-nowrap"
onClick={() => {
if (window.confirm(`Set all ${players.length} players to ${amount} XP?`)) {
onSet(parseInt(amount));
flash(`Set all players to ${amount} XP`);
}
}}
>
Set All XP
</button>
</div>
);
}
// Component for bulk RP giving
function BulkRPGiver({ onGive }) {
const [amount, setAmount] = useState('10');
return (
<div className="flex items-center gap-2">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
<button
className="text-xs px-3 py-1 rounded bg-blue-600/80 hover:bg-blue-600 text-white transition-colors whitespace-nowrap"
onClick={() => {
if (window.confirm(`Give ${amount} RP to all ${players.length} players?`)) {
onGive(parseInt(amount));
flash(`Gave ${amount} RP to all players`);
}
}}
>
Give RP to All
</button>
</div>
);
}
// Component for bulk RP setting
function BulkRPSetter({ onSet }) {
const [amount, setAmount] = useState('50');
return (
<div className="flex items-center gap-2">
<input
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
<button
className="text-xs px-3 py-1 rounded bg-blue-500/80 hover:bg-blue-500 text-white transition-colors whitespace-nowrap"
onClick={() => {
if (window.confirm(`Set all ${players.length} players to ${amount} RP?`)) {
onSet(parseInt(amount));
flash(`Set all players to ${amount} RP`);
}
}}
>
Set All RP
</button>
</div>
);
}
return (
<div className="bg-white/5 rounded-xl p-6 border border-white/10">
<h2 className="text-2xl font-semibold mb-4 text-white">Player Management</h2>
<p className="text-sm text-slate-300 mb-6">Manage player accounts, requisition points, experience, and renown.</p>
{saveMsg && (
<div className="mb-4 p-3 rounded bg-green-500/20 border border-green-500/30 text-green-300 text-sm">
{saveMsg}
</div>
)}
{/* Add New Player */}
<GmAddPlayer onAdd={gmAddOrUpdatePlayer} />
{/* Players List */}
<div className="mt-6">
<h3 className="text-lg font-semibold text-white mb-4">Current Players ({players.length})</h3>
{players.length === 0 ? (
<div className="text-slate-400 text-center py-8">
No players found. Add some players above.
</div>
) : (
<div className="space-y-4">
{players.map(player => (
<div key={player.name} className="bg-white/3 rounded-lg p-4 border border-white/5">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Player Info Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-white text-lg">{player.name}</div>
<div className="text-sm text-slate-400">
{player.renown || 'None'} RP: {player.requisitionPoints || 0}
</div>
</div>
<button
className="rounded bg-red-600 hover:bg-red-500 px-3 py-1.5 text-white text-sm font-medium transition-colors"
onClick={() => gmDeletePlayer(player.name)}
>
Delete Player
</button>
</div>
{/* Player Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="text-slate-300">
<span className="text-slate-400">Total XP:</span> {player.xp || 0}
</div>
<div className="text-slate-300">
<span className="text-slate-400">XP Spent:</span> {player.xpSpent || 0}
</div>
<div className="text-slate-300">
<span className="text-slate-400">Available XP:</span> {(player.xp || 0) - (player.xpSpent || 0)}
</div>
<div className="text-slate-300">
<span className="text-slate-400">Character:</span> {player.charName || 'Unnamed'}
</div>
</div>
</div>
{/* Management Controls Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* RP Management */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Requisition Points</label>
<GmSetRP
name={player.name}
currentRP={player.requisitionPoints}
onSet={gmSetRP}
/>
</div>
{/* XP Management */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Experience Points</label>
<GmSetXP
name={player.name}
currentXP={player.xp}
onSet={gmSetXP}
/>
</div>
{/* XP Spent Management */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">XP Spent</label>
<GmSetXPSpent
name={player.name}
currentXPSpent={player.xpSpent}
onSet={gmSetXPSpent}
/>
</div>
{/* Renown Management */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Renown Level</label>
<GmSetRenown
name={player.name}
currentRenown={player.renown}
onSet={gmSetRenown}
/>
</div>
{/* Password Reset */}
<div className="space-y-2 md:col-span-2">
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Password Reset</label>
<GmResetPW
name={player.name}
onReset={gmResetPlayerPw}
/>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Quick Actions */}
<div className="mt-6 p-4 bg-white/3 rounded-lg border border-white/5">
<h4 className="text-sm font-medium text-white mb-3">Quick Actions</h4>
{/* Bulk XP Actions */}
<div className="mb-4">
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Bulk XP Management</h5>
<div className="flex flex-wrap gap-2">
<BulkXPGiver onGive={(amount) => {
players.forEach(player => {
const currentXP = player.xp || 0;
gmSetXP(player.name, (currentXP + amount).toString());
});
}} />
<BulkXPSetter onSet={(amount) => {
players.forEach(player => {
gmSetXP(player.name, amount.toString());
});
}} />
</div>
</div>
{/* Bulk RP Actions */}
<div className="mb-4">
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Bulk RP Management</h5>
<div className="flex flex-wrap gap-2">
<BulkRPGiver onGive={(amount) => {
players.forEach(player => {
const currentRP = player.requisitionPoints || 0;
gmSetRP(player.name, (currentRP + amount).toString());
});
}} />
<BulkRPSetter onSet={(amount) => {
players.forEach(player => {
gmSetRP(player.name, amount.toString());
});
}} />
</div>
</div>
{/* Other Quick Actions */}
<div>
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Other Actions</h5>
<div className="flex flex-wrap gap-2">
<button
className="text-xs px-3 py-1 rounded bg-blue-600/80 hover:bg-blue-600 text-white transition-colors"
onClick={fetchPlayers}
>
Refresh Players
</button>
<button
className="text-xs px-3 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white transition-colors"
onClick={() => {
players.forEach(player => {
if ((player.requisitionPoints || 0) < 10) {
gmSetRP(player.name, '50');
}
});
}}
>
Set Low RP Players to 50
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,433 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import axios from 'axios';
import PlayerManagement from '../components/PlayerManagement';
// Mock axios
jest.mock('axios');
const mockedAxios = axios;
// Mock window.confirm
const mockConfirm = jest.fn();
Object.defineProperty(window, 'confirm', {
value: mockConfirm,
writable: true,
});
describe('PlayerManagement Component', () => {
const mockSessionId = 'test-session-123';
const mockAuthedPlayer = 'gm';
// Mock player data
const mockPlayers = [
{
name: 'TestPlayer',
requisitionPoints: 50,
xp: 1000,
xpSpent: 200,
renown: 'Respected',
charName: 'Brother Testicus'
}
];
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
mockConfirm.mockReturnValue(true);
// Mock successful API responses
mockedAxios.get.mockResolvedValue({ data: mockPlayers });
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
mockedAxios.delete.mockResolvedValue({ status: 200, data: { success: true } });
});
test('renders access denied for non-GM users', () => {
render(<PlayerManagement authedPlayer="player1" sessionId={mockSessionId} />);
expect(screen.getByText('Access Denied')).toBeInTheDocument();
expect(screen.getByText(/Player Management is only accessible to Game Masters/)).toBeInTheDocument();
});
test('renders player management interface for GM', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
expect(screen.getByText('Player Management')).toBeInTheDocument();
expect(screen.getByText(/Manage player accounts, requisition points/)).toBeInTheDocument();
// Wait for players to load
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
});
test('fetches and displays players on mount', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
// Wait for API call
await waitFor(() => {
expect(mockedAxios.get).toHaveBeenCalledWith('/api/players', {
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
});
});
// Check if player is displayed
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
expect(screen.getByText('Respected • RP: 50')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument(); // Total XP
expect(screen.getByText('200')).toBeInTheDocument(); // XP Spent
expect(screen.getByText('800')).toBeInTheDocument(); // Available XP
});
});
test('creates new player', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Player Name')).toBeInTheDocument();
});
// Fill in new player form
const nameInput = screen.getByPlaceholderText('Player Name');
const rpInput = screen.getByPlaceholderText('Requisition Points');
const pwInput = screen.getByPlaceholderText('Password (default: 1234)');
const addButton = screen.getByText('Add Player');
fireEvent.change(nameInput, { target: { value: 'NewPlayer' } });
fireEvent.change(rpInput, { target: { value: '75' } });
fireEvent.change(pwInput, { target: { value: 'testpass' } });
fireEvent.click(addButton);
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/add-or-update',
{
name: 'NewPlayer',
requisitionPoints: 75,
password: 'testpass'
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('sets requisition points for player', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find RP management section and use current RP value (50)
const rpInputs = screen.getAllByDisplayValue('50');
const rpInput = rpInputs.find(input => input.type === 'number');
const rpSetButtons = screen.getAllByText('Set');
const rpSetButton = rpSetButtons[0]; // First Set button should be for RP
// Click set button with current value
fireEvent.click(rpSetButton);
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/set-rp',
{
playerName: 'TestPlayer',
requisitionPoints: 50
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('sets experience points for player', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find XP management section and use current XP value (1000)
const xpInputs = screen.getAllByDisplayValue('1000');
const xpInput = xpInputs.find(input => input.type === 'number');
const xpSetButtons = screen.getAllByText('Set');
const xpSetButton = xpSetButtons[1]; // Second Set button should be for XP
// Click set button with current value
fireEvent.click(xpSetButton);
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/set-xp',
{
playerName: 'TestPlayer',
xp: 1000
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('sets XP spent for player', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find XP Spent management section and use current value (200)
const xpSpentInputs = screen.getAllByDisplayValue('200');
const xpSpentInput = xpSpentInputs.find(input => input.type === 'number');
const xpSpentSetButtons = screen.getAllByText('Set');
const xpSpentSetButton = xpSpentSetButtons[2]; // Third Set button should be for XP Spent
// Click set button with current value
fireEvent.click(xpSpentSetButton);
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/set-xp-spent',
{
playerName: 'TestPlayer',
xpSpent: 200
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('sets renown for player', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find renown dropdown and use current value (Respected)
const renownSelect = screen.getByDisplayValue('Respected');
const renownSetButtons = screen.getAllByText('Set');
const renownSetButton = renownSetButtons[3]; // Fourth Set button should be for Renown
// Click set button with current value
fireEvent.click(renownSetButton);
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/set-renown',
{
playerName: 'TestPlayer',
renown: 'Respected'
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('resets password for player', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find password reset section
const passwordInput = screen.getByPlaceholderText('New password (default: 1234)');
const resetButton = screen.getByText('Reset');
fireEvent.change(passwordInput, { target: { value: 'newpass123' } });
fireEvent.click(resetButton);
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/reset-password',
{
playerName: 'TestPlayer',
newPassword: 'newpass123'
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('deletes player with confirmation', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
const deleteButton = screen.getByText('Delete Player');
fireEvent.click(deleteButton);
// Check confirmation dialog
expect(mockConfirm).toHaveBeenCalledWith(
'Are you sure you want to delete player TestPlayer? This cannot be undone.'
);
await waitFor(() => {
expect(mockedAxios.delete).toHaveBeenCalledWith(
'/api/players/gm/delete/TestPlayer',
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('bulk gives XP to all players', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find bulk XP giver
const bulkXPInput = screen.getByDisplayValue('100');
const giveXPButton = screen.getByText('Give XP to All');
fireEvent.change(bulkXPInput, { target: { value: '250' } });
fireEvent.click(giveXPButton);
// Check confirmation
expect(mockConfirm).toHaveBeenCalledWith('Give 250 XP to all 1 players?');
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/set-xp',
{
playerName: 'TestPlayer',
xp: 1250 // 1000 + 250
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('bulk gives RP to all players', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Find bulk RP giver
const bulkRPInput = screen.getByDisplayValue('10');
const giveRPButton = screen.getByText('Give RP to All');
fireEvent.change(bulkRPInput, { target: { value: '25' } });
fireEvent.click(giveRPButton);
// Check confirmation
expect(mockConfirm).toHaveBeenCalledWith('Give 25 RP to all 1 players?');
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/gm/set-rp',
{
playerName: 'TestPlayer',
requisitionPoints: 75 // 50 + 25
},
{
headers: {
'Content-Type': 'application/json',
'x-session-id': mockSessionId,
'x-gm-secret': 'bongo'
}
}
);
});
});
test('handles API errors gracefully', async () => {
// Mock API error
mockedAxios.post.mockRejectedValueOnce({
response: { data: { message: 'Server error' } }
});
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
// Try to set RP and expect error handling
const rpInputs = screen.getAllByDisplayValue('50');
const rpInput = rpInputs.find(input => input.type === 'number');
const rpSetButtons = screen.getAllByText('Set');
const rpSetButton = rpSetButtons[0];
fireEvent.change(rpInput, { target: { value: '100' } });
fireEvent.click(rpSetButton);
// Should show error message
await waitFor(() => {
expect(screen.getByText(/Failed to set RP for TestPlayer: Server error/)).toBeInTheDocument();
});
});
test('refreshes players list', async () => {
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
await waitFor(() => {
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
const refreshButton = screen.getByText('Refresh Players');
fireEvent.click(refreshButton);
await waitFor(() => {
// Should make another API call
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
});
});
});