Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d139101
docs: Add Material icon sizing guideline
yurisim Oct 7, 2025
1160524
style: Remove Tailwind text sizing from Material icons
yurisim Oct 7, 2025
f6271bb
feat(api): Introduce aircraft subtype and callsign generation
yurisim Oct 7, 2025
e947147
feat(api): Add DTOs for aircraft spawning and direct allocation
yurisim Oct 7, 2025
05c9df7
feat(api): Implement GM aircraft spawning and direct allocation logic
yurisim Oct 7, 2025
bea05da
feat(client): Introduce AllocationSignalService for reactive state ma…
yurisim Oct 7, 2025
5ffda24
feat(client): Integrate AllocationSignalService into GameStats
yurisim Oct 7, 2025
e55afdb
feat(client): Implement GM aircraft spawn dialog
yurisim Oct 7, 2025
837c030
feat(client): Refactor CAOC dashboard for GM aircraft management and …
yurisim Oct 7, 2025
37a3941
feat(client): Enhance ATO table with allocated aircraft data
yurisim Oct 7, 2025
1170eab
feat(client): Auto-populate aircraft location in flight planner
yurisim Oct 7, 2025
85b8e01
feat(client): Implement responsive panel initial state
yurisim Oct 7, 2025
fc3d696
test(e2e): Add E2E tests for aircraft allocation and spawning
yurisim Oct 7, 2025
bd22dc2
Refactor E2E: Adjust map load test flow
yurisim Oct 7, 2025
e2cdbc6
feat(api): Enable conditional API throttling for production
Oct 8, 2025
2e4e4ad
refactor(api-e2e): Refactor JWT and continue game E2E tests
Oct 8, 2025
1f45f8f
feat(api): Implement aircraft pool upsert logic
Oct 8, 2025
0da588b
fix(api): Use sessionId for player lookups in allocation service
Oct 8, 2025
29d61be
refactor(e2e): Convert Playwright UI tests to API E2E tests
Oct 8, 2025
8e31b0f
feat(ui): Implement autocomplete for aircraft spawn locations
Oct 8, 2025
83dc847
feat(ui): Enhance GM player management with role & team submenus
Oct 8, 2025
69c7c85
chore(claude): Add new jest commands to Claude settings
Oct 8, 2025
8876e2d
docs(e2e): Update CLAUDE.md with E2E testing notes
Oct 8, 2025
aa88123
chore(e2e): Update global-setup message for API E2E tests
Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_install",
"Bash(npx playwright:*)",
"Bash(cat:*)"
"Bash(cat:*)",
"Bash(BASE_URL=http://localhost:3001 npx jest aircraft-allocation.spec.ts --runInBand --verbose)",
"Bash(PORT=3001 npx jest aircraft-allocation.spec.ts --runInBand)",
"Bash(set PORT=3001)",
"Bash(PORT=3001 npx jest aircraft-allocation.spec.ts --testNamePattern=\"should spawn C-130\" --runInBand --verbose)",
"Bash(npx ng build:*)"
],
"deny": [],
"ask": []
Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ npx nx prisma-db-push pac-shield-api
# Testing & Quality
npx nx test pac-shield
npx nx lint pac-shield

# E2E Testing
npx nx e2e pac-shield-e2e # Playwright E2E tests
cd apps/pac-shield-api-e2e && npx jest # API E2E tests (requires pac-shield-api on port 3000)
```

### 🧪 E2E Testing Notes
- **API E2E tests** (`apps/pac-shield-api-e2e`) assume `pac-shield-api` is running on **port 3000**
- **NEVER kill port 3000** during API E2E test runs - tests expect the server to be running
- Run `npx nx serve pac-shield-api` in a separate terminal before running API E2E tests
```

## 🚨 CRITICAL RULES
Expand All @@ -49,6 +59,11 @@ npx nx lint pac-shield
- **Styling**: Tailwind utilities only, no custom CSS files
- **Control Flow**: `@if/@for/@switch` only, no `*ngIf/*ngFor/*ngSwitch`
- **Imports**: Direct paths only, no barrel exports
- **Icons**: NEVER use Tailwind text size classes on `<mat-icon>` elements
- **Why**: Material icons have built-in sizing that works with Material Design typography
- **Wrong**: `<mat-icon class="text-4xl">icon</mat-icon>`
- **Correct**: `<mat-icon>icon</mat-icon>`
- **Check**: `grep -r "mat-icon.*text-[0-9xs]" apps/pac-shield/src/` must return zero

### 🗃️ Database Schema & DTO Generation
1. Edit `apps/pac-shield-api/src/prisma/schema.prisma`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ import axios, { AxiosError } from 'axios';
describe('JWT and Continue Game API E2E', () => {
let gameId: number;
let roomCode: string;
let playerToken: string;
let playerId: number;

beforeEach(async () => {
// Create a fresh game for each test
Expand All @@ -37,6 +35,16 @@ describe('JWT and Continue Game API E2E', () => {
roomCode = createRes.data.roomCode;
});

/**
* Tests the GET /api/game/validate/:roomCode endpoint.
*
* Purpose: Validate room codes before joining a game to provide user-friendly feedback.
*
* Scenarios:
* - Valid room code returns {valid: true, gameId: number}
* - Invalid/non-existent room code returns {valid: false}
* - Malformed room codes (special chars, too short/long) return {valid: false}
*/
describe('GET /api/game/validate/:roomCode', () => {
it('should validate existing room code', async () => {
const res = await axios.get(`/api/game/validate/${roomCode}`);
Expand Down Expand Up @@ -67,9 +75,27 @@ describe('JWT and Continue Game API E2E', () => {
const shortRes = await axios.get(`/api/game/validate/X`);
expect(shortRes.status).toBe(200);
expect(shortRes.data.valid).toBe(false);

// Test with long code
const longRes = await axios.get(`/api/game/validate/VERYLONGCODE123`);
expect(longRes.status).toBe(200);
expect(longRes.data.valid).toBe(false);
});
});

/**
* Tests PIN-based player authentication and session resumption.
*
* Purpose: Verify secure player identity management using PINs.
*
* Scenarios:
* - New player joins with PIN -> creates player, returns JWT
* - Existing player name without PIN -> NAME_CONFLICT error
* - Existing player with correct PIN -> returns same player ID, new JWT
* - Existing player with wrong PIN -> INVALID_PIN error
* - Legacy player (no PIN) + attempt with PIN -> NO_PIN_SET error
* - ConflictUser scenario (mirrors frontend E2E test workflow)
*/
describe('PIN-based player management', () => {
it('should create new player with PIN', async () => {
const joinRes = await axios.post(`/api/player/join`, {
Expand All @@ -83,9 +109,6 @@ describe('JWT and Continue Game API E2E', () => {
expect(joinRes.data).toHaveProperty('player');
expect(joinRes.data.player.name).toBe('TestPlayer');
// PIN should be stored in the database

playerToken = joinRes.data.token;
playerId = joinRes.data.player.id;
});

it('should detect name conflict when joining with existing name without PIN', async () => {
Expand Down Expand Up @@ -223,6 +246,16 @@ describe('JWT and Continue Game API E2E', () => {
});
});

/**
* Tests basic player creation and JWT generation.
*
* Purpose: Verify player creation works with and without PINs, and JWTs are properly formatted.
*
* Scenarios:
* - Create player without PIN (legacy support)
* - Create player with PIN
* - Validate JWT structure (3-part token: header.payload.signature)
*/
describe('Player creation and basic functionality', () => {
it('should create player without PIN (legacy support)', async () => {
const joinRes = await axios.post(`/api/player/join`, {
Expand Down Expand Up @@ -266,6 +299,15 @@ describe('JWT and Continue Game API E2E', () => {
});
});

/**
* Tests player name isolation across different games.
*
* Purpose: Verify that player names are scoped to individual games, not globally.
*
* Scenarios:
* - Same player name in different games -> creates separate player records
* - No name conflicts detected across different games
*/
describe('Multiple games and player isolation', () => {
let secondGameRoomCode: string;

Expand Down Expand Up @@ -319,6 +361,17 @@ describe('JWT and Continue Game API E2E', () => {
});
});

/**
* Tests error handling and edge cases for player join endpoint.
*
* Purpose: Verify robust error handling and validation.
*
* Scenarios:
* - Invalid room code -> 404 Not Found
* - Missing required fields -> 400 Bad Request
* - Empty player name -> 400 Bad Request
* - null/undefined PIN -> treated as no PIN (legacy support)
*/
describe('Error handling and edge cases', () => {
it('should handle invalid room codes', async () => {
try {
Expand Down Expand Up @@ -373,37 +426,4 @@ describe('JWT and Continue Game API E2E', () => {
expect(joinRes.data.player.name).toBe('NoPin Player');
});
});

describe('Game validation endpoint comprehensive tests', () => {
it('should validate room codes correctly', async () => {
// Test with valid room code
const validRes = await axios.get(`/api/game/validate/${roomCode}`);
expect(validRes.status).toBe(200);
expect(validRes.data.valid).toBe(true);
expect(validRes.data.gameId).toBe(gameId);

// Test with invalid room code
const invalidRes = await axios.get(`/api/game/validate/FAKE123`);
expect(invalidRes.status).toBe(200);
expect(invalidRes.data.valid).toBe(false);
expect(invalidRes.data.gameId).toBeUndefined();
});

it('should handle various room code formats', async () => {
// Test with short code
const shortRes = await axios.get(`/api/game/validate/ABC`);
expect(shortRes.status).toBe(200);
expect(shortRes.data.valid).toBe(false);

// Test with long code
const longRes = await axios.get(`/api/game/validate/VERYLONGCODE123`);
expect(longRes.status).toBe(200);
expect(longRes.data.valid).toBe(false);

// Test with special characters
const specialRes = await axios.get(`/api/game/validate/ABC-123`);
expect(specialRes.status).toBe(200);
expect(specialRes.data.valid).toBe(false);
});
});
});
2 changes: 1 addition & 1 deletion apps/pac-shield-api-e2e/src/support/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ module.exports = async function () {
// If port is already in use, assume API is running and skip any startup.
const inUse = await isPortInUse(port, host, 1000);
if (inUse) {
console.log(`[api-e2e] Port ${port} is in use; skipping server startup.`);
console.log(`[api-e2e] skipping ${port} server startup, alrready up.`);
// Teardown remains safe: we did not start anything, so nothing to kill.
globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n';
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const mockPrismaService = {
},
aircraftPool: {
create: jest.fn(),
upsert: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
Expand Down Expand Up @@ -227,15 +228,15 @@ describe('AircraftPoolService', () => {
// Mock getAircraftPool to return previous turn's pools
jest.spyOn(service, 'getAircraftPool').mockResolvedValue(previousPools as any);

prismaService.aircraftPool.create
prismaService.aircraftPool.upsert
.mockResolvedValueOnce(newPools[0])
.mockResolvedValueOnce(newPools[1])
.mockResolvedValueOnce(newPools[2]);

const result = await service.processApportionment(gameId, turn, executionBlock);

expect(result).toHaveLength(3);
expect(prismaService.aircraftPool.create).toHaveBeenCalledTimes(3);
expect(prismaService.aircraftPool.upsert).toHaveBeenCalledTimes(3);
});

it('should handle USTRANSCOM C-5 delivery schedule correctly', async () => {
Expand Down Expand Up @@ -269,7 +270,7 @@ describe('AircraftPoolService', () => {
maintenanceCount: 0,
};

prismaService.aircraftPool.create.mockResolvedValue(expectedC5Pool);
prismaService.aircraftPool.upsert.mockResolvedValue(expectedC5Pool);

const result = await service.processApportionment(gameId, turn, executionBlock);

Expand Down
20 changes: 17 additions & 3 deletions apps/pac-shield-api/src/app/allocation/aircraft-pool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,23 @@ export class AircraftPoolService {
// Process random events (maintenance, etc.)
newCounts = this.processRandomEvents(newCounts, aircraftType);

// Create new pool entry for this turn
const pool = await this.prisma.aircraftPool.create({
data: {
// Upsert pool entry for this turn (update if exists, create if not)
const pool = await this.prisma.aircraftPool.upsert({
where: {
gameId_turn_executionBlock_aircraftType: {
gameId,
turn,
executionBlock,
aircraftType,
},
},
update: {
availableCount: newCounts.available,
allocatedCount: newCounts.allocated,
inTransitCount: newCounts.inTransit,
maintenanceCount: newCounts.maintenance,
},
create: {
gameId,
turn,
executionBlock,
Expand Down
71 changes: 71 additions & 0 deletions apps/pac-shield-api/src/app/allocation/allocation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { CreateAircraftRequestDto } from './dto/create-aircraft-request.dto';
import { UpdateAircraftRequestDto } from './dto/update-aircraft-request.dto';
import { ReviewAircraftRequestDto } from './dto/review-aircraft-request.dto';
import { CreateAircraftAllocationDto } from './dto/create-aircraft-allocation.dto';
import { SpawnAircraftDto } from './dto/spawn-aircraft.dto';
import { DirectAllocationDto } from './dto/direct-allocation.dto';

/**
* Controller for CFACC aircraft allocation operations.
Expand Down Expand Up @@ -308,4 +310,73 @@ export class AllocationController {
): Promise<AircraftAllocation[]> {
return this.allocationService.getAllocationsForCycle(cycleId);
}

// =============================================
// GM AIRCRAFT SPAWNING ENDPOINTS
// =============================================

/**
* Spawn a new aircraft instance (GM only)
* POST /allocation/aircraft/spawn
*/
@Post('aircraft/spawn')
async spawnAircraft(
@Body() dto: SpawnAircraftDto,
@Request() req: any
): Promise<AircraftInstance> {
return this.allocationService.spawnAircraft(
dto.gameId,
dto.type,
dto.subtype || null,
dto.teamId,
dto.rangeHexes,
dto.locationFosId,
dto.locationHex,
req.user
);
}

/**
* Delete an unallocated aircraft (GM only)
* DELETE /allocation/aircraft/:id
*/
@Delete('aircraft/:id')
async deleteAircraft(
@Param('id', ParseIntPipe) id: number,
@Request() req: any
): Promise<void> {
return this.allocationService.deleteUnallocatedAircraft(id, req.user);
}

/**
* Get all aircraft for a game (GM view)
* GET /allocation/aircraft/game/:gameId
*/
@Get('aircraft/game/:gameId')
async getAllAircraft(
@Param('gameId', ParseIntPipe) gameId: number
): Promise<AircraftInstance[]> {
return this.allocationService.getAllAircraftForGame(gameId);
}

// =============================================
// DIRECT ALLOCATION ENDPOINT
// =============================================

/**
* Directly allocate an aircraft to a team (CFACC/GM only)
* POST /allocation/allocate
*/
@Post('allocate')
async directAllocate(
@Body() dto: DirectAllocationDto,
@Request() req: any
): Promise<AircraftAllocation> {
return this.allocationService.directAllocateAircraft(
dto.aircraftInstanceId,
dto.allocatedToTeamId,
dto.allocationCycleId,
req.user
);
}
}
Loading