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
+8 -1
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}
/>}
+656
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>
);
}
+433
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);
});
});
});