All 11 issues from 4-player simulation of The Hunt for Fabius Bile: 1. Roll feed visible to all players (no GM auth guard) 2. Scene text gated by revealed flag; GM explicit reveal per scene 3. GM can add NPC/enemy entries to initiative tracker 4. Round counter synced to Dice Roller via localStorage + custom event 5. Check reward text hidden until player has rolled 6. Checks assignable to specific player via Scene Secrets ⚙ options 7. Fear test quick-roll panel with WP input appears when fearRating > 0 8. Decision checks show textarea/Declare instead of d100 roll button 9. Fate point re-roll: one per check per scene, resets on scene advance 10. Player poll reduced from 8s to 4s, combined mission + roll feed poll 11. Mission complete banner with scene stats and GM outcome notes field 4 follow-up findings from second simulation run: - Finding #1: Activate mission now initialises revealed:false on all scenes so players never see scene text before the GM narrates - Finding #2: Fear penalty auto-applied to WP display; button shows effective (penalised) target rather than raw WP input - Finding #3: RollFeedRow moved outside component to avoid re-mount on every render; onDelete passed as prop - Finding #4: Removed duplicate "Open for Players" quick-button from Scene Checks left panel — Scene Secrets is the sole entry point New files: - src/tests/missionPlaythrough.test.js — full GM+4-player simulation test suite covering all 11 issues and 4 findings (39 test cases) - src/tests/missionTab.test.js — player/GM view isolation tests - src/utils/diceRoller.js — shared d100/degrees/clampTarget utilities - tests/missionRoutes.test.js — backend mission route unit tests - tests/playerRoutesLogin.test.js — player login route tests - tests/sessionRoutes.test.js — session validation tests Note: React unit tests require jsdom; segfaults on ARM64 (Raspberry Pi) due to a known jsdom/Node 20 incompatibility on aarch64. Tests pass on x86 CI. Backend integration tests (tests/) run normally. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
248 lines
7.3 KiB
JavaScript
248 lines
7.3 KiB
JavaScript
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import '@testing-library/jest-dom';
|
|
import axios from 'axios';
|
|
import App from '../App';
|
|
|
|
// Mock axios
|
|
jest.mock('axios');
|
|
const mockedAxios = axios;
|
|
|
|
// Mock localStorage
|
|
const localStorageMock = {
|
|
getItem: jest.fn(),
|
|
setItem: jest.fn(),
|
|
removeItem: jest.fn(),
|
|
clear: jest.fn(),
|
|
};
|
|
global.localStorage = localStorageMock;
|
|
|
|
describe('Login Functionality', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
|
|
// Mock axios to handle different endpoints
|
|
mockedAxios.get.mockImplementation((url) => {
|
|
if (url === '/api/players/names') {
|
|
return Promise.resolve({
|
|
data: [
|
|
{ name: 'gm' },
|
|
{ name: 'anders' },
|
|
{ name: 'phillip' }
|
|
]
|
|
});
|
|
}
|
|
if (url === '/api/missions') {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
if (url === '/api/missions/active/current') {
|
|
return Promise.resolve({ data: null });
|
|
}
|
|
if (url === '/api/missions/active/player') {
|
|
return Promise.resolve({ data: null });
|
|
}
|
|
// For other GET requests (like /api/players/{name})
|
|
return Promise.resolve({
|
|
data: {
|
|
name: 'gm',
|
|
tabInfo: {
|
|
rp: 100,
|
|
xp: 1000,
|
|
xpSpent: 200,
|
|
renown: 'Respected',
|
|
charName: 'Game Master'
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Default POST mock - will be overridden per test with mockResolvedValueOnce
|
|
mockedAxios.post.mockImplementation((url, data) => {
|
|
if (url === '/api/players/login') {
|
|
return Promise.resolve({
|
|
data: {
|
|
success: true,
|
|
sessionId: 'default_session_id',
|
|
player: { name: data.name }
|
|
},
|
|
status: 200
|
|
});
|
|
}
|
|
if (url === '/api/sessions/validate') {
|
|
const sessionId = data?.sessionId || '';
|
|
const playerName = sessionId.includes('anders') ? 'anders' : sessionId.includes('gm') ? 'gm' : '';
|
|
return Promise.resolve({
|
|
data: { valid: Boolean(playerName), playerName },
|
|
status: 200
|
|
});
|
|
}
|
|
return Promise.resolve({ status: 200 });
|
|
});
|
|
});
|
|
|
|
test('login with GM account', async () => {
|
|
mockedAxios.post.mockResolvedValueOnce({
|
|
data: {
|
|
success: true,
|
|
sessionId: 'session_gm_12345',
|
|
player: { name: 'gm' },
|
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
|
},
|
|
status: 200
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the login form to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/username/i)).toBeInTheDocument();
|
|
});
|
|
|
|
const nameInput = screen.getByPlaceholderText(/username/i);
|
|
const passwordInput = screen.getByPlaceholderText(/password/i);
|
|
const loginButton = screen.getByRole('button', { name: /login/i });
|
|
|
|
// Fill in login form with GM credentials
|
|
fireEvent.change(nameInput, { target: { value: 'gm' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'bongo' } });
|
|
|
|
// Click login button
|
|
fireEvent.click(loginButton);
|
|
|
|
// Verify login was called with correct parameters
|
|
await waitFor(() => {
|
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
'/api/players/login',
|
|
{
|
|
name: 'gm',
|
|
password: 'bongo'
|
|
}
|
|
);
|
|
});
|
|
|
|
// Verify success message appears
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/login successful/i)).toBeInTheDocument();
|
|
}, { timeout: 3000 });
|
|
});
|
|
|
|
test('login with invalid password fails', async () => {
|
|
mockedAxios.post.mockImplementation((url) => {
|
|
if (url === '/api/players/login') {
|
|
return Promise.reject({
|
|
response: {
|
|
status: 401,
|
|
data: { error: 'Invalid username or password' }
|
|
},
|
|
message: 'Request failed with status code 401'
|
|
});
|
|
}
|
|
return Promise.resolve({ data: {}, status: 200 });
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/username/i)).toBeInTheDocument();
|
|
});
|
|
|
|
const nameInput = screen.getByPlaceholderText(/username/i);
|
|
const passwordInput = screen.getByPlaceholderText(/password/i);
|
|
const loginButton = screen.getByRole('button', { name: /login/i });
|
|
|
|
fireEvent.change(nameInput, { target: { value: 'gm' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'wrong_password' } });
|
|
fireEvent.click(loginButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/login failed/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test('login with non-existent player fails', async () => {
|
|
mockedAxios.post.mockImplementation((url) => {
|
|
if (url === '/api/players/login') {
|
|
return Promise.reject({
|
|
response: {
|
|
status: 401,
|
|
data: { error: 'Invalid username or password' }
|
|
},
|
|
message: 'Request failed with status code 401'
|
|
});
|
|
}
|
|
return Promise.resolve({ data: {}, status: 200 });
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/username/i)).toBeInTheDocument();
|
|
});
|
|
|
|
const nameInput = screen.getByPlaceholderText(/username/i);
|
|
const passwordInput = screen.getByPlaceholderText(/password/i);
|
|
const loginButton = screen.getByRole('button', { name: /login/i });
|
|
|
|
fireEvent.change(nameInput, { target: { value: 'nonexistent' } });
|
|
fireEvent.change(passwordInput, { target: { value: '1234' } });
|
|
fireEvent.click(loginButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/login failed/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test('login with player account', async () => {
|
|
mockedAxios.post.mockResolvedValueOnce({
|
|
data: {
|
|
success: true,
|
|
sessionId: 'session_anders_12345',
|
|
player: { name: 'anders' },
|
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
|
},
|
|
status: 200
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the login form to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/username/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// Find login inputs
|
|
const nameInput = screen.getByPlaceholderText(/username/i);
|
|
const passwordInput = screen.getByPlaceholderText(/password/i);
|
|
const loginButton = screen.getByRole('button', { name: /login/i });
|
|
|
|
// Fill in login form with player credentials
|
|
fireEvent.change(nameInput, { target: { value: 'anders' } });
|
|
fireEvent.change(passwordInput, { target: { value: '1234' } });
|
|
|
|
// Click login button
|
|
fireEvent.click(loginButton);
|
|
|
|
// Verify login was called with correct parameters
|
|
await waitFor(() => {
|
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
'/api/players/login',
|
|
{
|
|
name: 'anders',
|
|
password: '1234'
|
|
}
|
|
);
|
|
});
|
|
|
|
// Verify success message appears
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/login successful/i)).toBeInTheDocument();
|
|
}, { timeout: 3000 });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/active mission/i).length).toBeGreaterThan(0);
|
|
});
|
|
expect(screen.queryByText(/mission is only accessible to game masters/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|