Files
dwroller/src/tests/login.test.js
T
alexandClaude Sonnet 4.6 cdfd147f02 feat: Mission Tab — 11 playthrough fixes + 4 follow-up findings
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]>
2026-06-24 00:25:51 +02:00

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();
});
});