mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
docs: Add API reference and testing guide
Complete documentation for: - 04_API_REFERENCE.md: All 9 API endpoints with examples - 05_TESTING_GUIDE.md: Minitest strategy with fixtures and tests These complete the documentation set along with the Hacktivity integration guide.
This commit is contained in:
@@ -0,0 +1,845 @@
|
||||
# API Reference
|
||||
|
||||
Complete API documentation for BreakEscape Rails Engine.
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
When mounted in Hacktivity:
|
||||
```
|
||||
https://hacktivity.com/break_escape
|
||||
```
|
||||
|
||||
When running standalone:
|
||||
```
|
||||
http://localhost:3000/break_escape
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints use **session-based authentication** via Rails cookies.
|
||||
|
||||
### Headers Required
|
||||
|
||||
```http
|
||||
Cookie: _session_id=... # Rails session cookie (set by Devise)
|
||||
X-CSRF-Token: <token> # CSRF token (from form_authenticity_token)
|
||||
Content-Type: application/json # For POST/PUT requests
|
||||
Accept: application/json # For JSON responses
|
||||
```
|
||||
|
||||
### Getting CSRF Token
|
||||
|
||||
The token is available in the game view:
|
||||
|
||||
```javascript
|
||||
const csrfToken = window.breakEscapeConfig.csrfToken;
|
||||
// or
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. GET /missions
|
||||
|
||||
Get list of available missions (scenarios).
|
||||
|
||||
**URL:** `/break_escape/missions`
|
||||
|
||||
**Method:** `GET`
|
||||
|
||||
**Auth:** None required
|
||||
|
||||
**Query Parameters:** None
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html
|
||||
|
||||
<!-- Renders missions/index.html.erb -->
|
||||
```
|
||||
|
||||
**HTML Response includes:**
|
||||
- List of published missions
|
||||
- Mission cards with title, description, difficulty
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
curl https://hacktivity.com/break_escape/missions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. GET /missions/:id
|
||||
|
||||
Select a mission and create/find game instance.
|
||||
|
||||
**URL:** `/break_escape/missions/:id`
|
||||
|
||||
**Method:** `GET`
|
||||
|
||||
**Auth:** Required (current_user or current_player)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Mission ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 302 Found
|
||||
Location: /break_escape/games/123
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Finds or creates game instance for current player
|
||||
- Redirects to game show page
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
curl -X GET https://hacktivity.com/break_escape/missions/1 \
|
||||
-H "Cookie: _session_id=..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. GET /games/:id
|
||||
|
||||
Show game view (HTML page with Phaser game).
|
||||
|
||||
**URL:** `/break_escape/games/:id`
|
||||
|
||||
**Method:** `GET`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```html
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mission Name - BreakEscape</title>
|
||||
<!-- Game CSS -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="break-escape-game"></div>
|
||||
<script nonce="...">
|
||||
window.breakEscapeConfig = {
|
||||
gameId: 123,
|
||||
apiBasePath: '/break_escape/games/123',
|
||||
csrfToken: '...'
|
||||
};
|
||||
</script>
|
||||
<!-- Game JS -->
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
curl https://hacktivity.com/break_escape/games/123 \
|
||||
-H "Cookie: _session_id=..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. GET /games/:id/scenario
|
||||
|
||||
Get scenario JSON for this game instance.
|
||||
|
||||
**URL:** `/break_escape/games/:id/scenario`
|
||||
|
||||
**Method:** `GET`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"scenarioName": "CEO Exfiltration",
|
||||
"scenarioBrief": "Gather evidence of insider trading",
|
||||
"startRoom": "reception",
|
||||
"rooms": {
|
||||
"reception": {
|
||||
"type": "room_reception",
|
||||
"connections": {
|
||||
"north": "office"
|
||||
},
|
||||
"locked": false,
|
||||
"objects": [
|
||||
{
|
||||
"type": "desk",
|
||||
"name": "Reception Desk",
|
||||
"observations": "A tidy desk with a computer monitor"
|
||||
}
|
||||
]
|
||||
},
|
||||
"office": {
|
||||
"type": "room_office",
|
||||
"connections": {
|
||||
"south": "reception"
|
||||
},
|
||||
"locked": true,
|
||||
"objects": []
|
||||
}
|
||||
},
|
||||
"npcs": [
|
||||
{
|
||||
"id": "security_guard",
|
||||
"displayName": "Security Guard",
|
||||
"storyPath": "scenarios/ink/security-guard.json",
|
||||
"npcType": "person"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Scenario is **ERB-generated** when game instance was created
|
||||
- Each game has **unique passwords/pins**
|
||||
- Solutions are **included** (server-side only, not sent to client via filtered endpoints)
|
||||
- This endpoint returns the **complete** scenario (use with care)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const scenario = await ApiClient.getScenario();
|
||||
```
|
||||
|
||||
```bash
|
||||
curl https://hacktivity.com/break_escape/games/123/scenario \
|
||||
-H "Cookie: _session_id=..." \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. GET /games/:id/ink
|
||||
|
||||
Get NPC Ink script (JIT compiled if needed).
|
||||
|
||||
**URL:** `/break_escape/games/:id/ink?npc=<npc_id>`
|
||||
|
||||
**Method:** `GET`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
- `npc` (query) - NPC identifier
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"inkVersion": 21,
|
||||
"root": [
|
||||
["^Hello there! I'm the security guard.", "\n"],
|
||||
["^What brings you here?", "\n"],
|
||||
["ev", "str", "^Ask about access", "/str", "/ev", {"->": ".^.c", "c": true}],
|
||||
["ev", "str", "^Goodbye", "/str", "/ev", {"->": ".^.c", "c": true}]
|
||||
],
|
||||
"listDefs": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Checks if NPC exists in game's scenario_data
|
||||
- Looks for .ink source file
|
||||
- Compiles .ink → .json if:
|
||||
- .json doesn't exist, OR
|
||||
- .ink is newer than .json
|
||||
- Compilation takes ~300ms (cached thereafter)
|
||||
- Returns compiled Ink JSON
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
```json
|
||||
// Missing npc parameter
|
||||
HTTP/1.1 400 Bad Request
|
||||
{"error": "Missing npc parameter"}
|
||||
|
||||
// NPC not in scenario
|
||||
HTTP/1.1 404 Not Found
|
||||
{"error": "NPC not found in scenario"}
|
||||
|
||||
// Ink file not found
|
||||
HTTP/1.1 404 Not Found
|
||||
{"error": "Ink script not found"}
|
||||
|
||||
// Compilation failed
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
{"error": "Invalid JSON in compiled ink: ..."}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const inkScript = await ApiClient.getNPCScript('security_guard');
|
||||
```
|
||||
|
||||
```bash
|
||||
curl "https://hacktivity.com/break_escape/games/123/ink?npc=security_guard" \
|
||||
-H "Cookie: _session_id=..." \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. GET /games/:id/bootstrap
|
||||
|
||||
Get initial game data for client.
|
||||
|
||||
**URL:** `/break_escape/games/:id/bootstrap`
|
||||
|
||||
**Method:** `GET`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"gameId": 123,
|
||||
"missionName": "CEO Exfiltration",
|
||||
"startRoom": "reception",
|
||||
"playerState": {
|
||||
"currentRoom": "reception",
|
||||
"unlockedRooms": ["reception"],
|
||||
"unlockedObjects": [],
|
||||
"inventory": [],
|
||||
"encounteredNPCs": [],
|
||||
"globalVariables": {},
|
||||
"biometricSamples": [],
|
||||
"biometricUnlocks": [],
|
||||
"bluetoothDevices": [],
|
||||
"notes": [],
|
||||
"health": 100
|
||||
},
|
||||
"roomLayout": {
|
||||
"reception": {
|
||||
"connections": {"north": "office"},
|
||||
"locked": false
|
||||
},
|
||||
"office": {
|
||||
"connections": {"south": "reception"},
|
||||
"locked": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- `roomLayout` includes connections and locked status
|
||||
- `roomLayout` does **NOT** include lockType or requires (solutions hidden)
|
||||
- `playerState` includes all current progress
|
||||
- Use this to initialize client game state
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const gameData = await ApiClient.bootstrap();
|
||||
```
|
||||
|
||||
```bash
|
||||
curl https://hacktivity.com/break_escape/games/123/bootstrap \
|
||||
-H "Cookie: _session_id=..." \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. PUT /games/:id/sync_state
|
||||
|
||||
Sync player state to server.
|
||||
|
||||
**URL:** `/break_escape/games/:id/sync_state`
|
||||
|
||||
**Method:** `PUT`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentRoom": "office",
|
||||
"globalVariables": {
|
||||
"alarm_triggered": false,
|
||||
"player_favor": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Updates `player_state.currentRoom` if provided
|
||||
- Merges `globalVariables` into `player_state.globalVariables`
|
||||
- Does NOT validate - trusts client for these fields
|
||||
- Saves to database
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
await ApiClient.syncState('office', {
|
||||
alarm_triggered: false,
|
||||
player_favor: 5
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X PUT https://hacktivity.com/break_escape/games/123/sync_state \
|
||||
-H "Cookie: _session_id=..." \
|
||||
-H "X-CSRF-Token: ..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"currentRoom": "office",
|
||||
"globalVariables": {
|
||||
"alarm_triggered": false
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. POST /games/:id/unlock
|
||||
|
||||
Validate unlock attempt (server-side).
|
||||
|
||||
**URL:** `/break_escape/games/:id/unlock`
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"targetType": "door",
|
||||
"targetId": "office",
|
||||
"attempt": "password123",
|
||||
"method": "password"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `targetType` (string) - "door" or "object"
|
||||
- `targetId` (string) - Room ID or object ID
|
||||
- `attempt` (string) - Password, PIN, or key ID
|
||||
- `method` (string) - "password", "pin", "key", or "lockpick"
|
||||
|
||||
**Response (Success - Door):**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"success": true,
|
||||
"type": "door",
|
||||
"roomData": {
|
||||
"type": "room_office",
|
||||
"connections": {"south": "reception"},
|
||||
"objects": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Success - Object):**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"success": true,
|
||||
"type": "object"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Failure):**
|
||||
|
||||
```json
|
||||
HTTP/1.1 422 Unprocessable Entity
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"success": false,
|
||||
"message": "Invalid attempt"
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Validates attempt against scenario_data (solutions)
|
||||
- For passwords/pins: Compares string match
|
||||
- For keys: Compares key ID
|
||||
- For lockpick: Always succeeds (client minigame already validated)
|
||||
- If valid:
|
||||
- Updates player_state (adds to unlockedRooms or unlockedObjects)
|
||||
- Returns filtered room/object data (no solutions)
|
||||
- If invalid:
|
||||
- Returns error, no state change
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const result = await ApiClient.unlock('door', 'office', 'admin123', 'password');
|
||||
if (result.success) {
|
||||
// Unlock succeeded
|
||||
console.log('Room unlocked!', result.roomData);
|
||||
} else {
|
||||
// Invalid password
|
||||
console.log('Failed:', result.message);
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST https://hacktivity.com/break_escape/games/123/unlock \
|
||||
-H "Cookie: _session_id=..." \
|
||||
-H "X-CSRF-Token: ..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"targetType": "door",
|
||||
"targetId": "office",
|
||||
"attempt": "admin123",
|
||||
"method": "password"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. POST /games/:id/inventory
|
||||
|
||||
Update player inventory.
|
||||
|
||||
**URL:** `/break_escape/games/:id/inventory`
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**Auth:** Required (must be game owner or admin)
|
||||
|
||||
**Parameters:**
|
||||
- `id` (path) - Game instance ID
|
||||
|
||||
**Request Body (Add Item):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"item": {
|
||||
"type": "key",
|
||||
"name": "Office Key",
|
||||
"key_id": "office_key_1",
|
||||
"takeable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request Body (Remove Item):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "remove",
|
||||
"item": {
|
||||
"id": "office_key_1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"success": true,
|
||||
"inventory": [
|
||||
{
|
||||
"type": "key",
|
||||
"name": "Office Key",
|
||||
"key_id": "office_key_1",
|
||||
"takeable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response:**
|
||||
|
||||
```json
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"success": false,
|
||||
"message": "Invalid action"
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- `add`: Appends item to player_state.inventory
|
||||
- `remove`: Removes item with matching ID from inventory
|
||||
- No validation (trusts client)
|
||||
- Returns updated inventory array
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
// Add item
|
||||
await ApiClient.updateInventory('add', {
|
||||
type: 'key',
|
||||
name: 'Office Key',
|
||||
key_id: 'office_key_1'
|
||||
});
|
||||
|
||||
// Remove item
|
||||
await ApiClient.updateInventory('remove', { id: 'office_key_1' });
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST https://hacktivity.com/break_escape/games/123/inventory \
|
||||
-H "Cookie: _session_id=..." \
|
||||
-H "X-CSRF-Token: ..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"action": "add",
|
||||
"item": {
|
||||
"type": "key",
|
||||
"name": "Office Key"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Standard Error Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error message here"
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Code | Meaning | When |
|
||||
|------|---------|------|
|
||||
| 200 | OK | Successful request |
|
||||
| 302 | Found | Redirect (e.g., mission → game) |
|
||||
| 400 | Bad Request | Missing required parameters |
|
||||
| 401 | Unauthorized | Not logged in |
|
||||
| 403 | Forbidden | Not authorized (Pundit) |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
| 422 | Unprocessable Entity | Validation failed (e.g., invalid password) |
|
||||
| 500 | Internal Server Error | Server error (e.g., compilation failed) |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Currently **no rate limiting** is implemented. Consider adding in production:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem 'rack-attack'
|
||||
|
||||
# config/initializers/rack_attack.rb
|
||||
Rack::Attack.throttle('api/ip', limit: 100, period: 1.minute) do |req|
|
||||
req.ip if req.path.start_with?('/break_escape/games/')
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Client (JavaScript)
|
||||
|
||||
### Installation
|
||||
|
||||
The API client is provided in `public/break_escape/js/api-client.js`.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
import { ApiClient } from './api-client.js';
|
||||
|
||||
// Bootstrap
|
||||
const gameData = await ApiClient.bootstrap();
|
||||
|
||||
// Get scenario
|
||||
const scenario = await ApiClient.getScenario();
|
||||
|
||||
// Get NPC script
|
||||
const inkScript = await ApiClient.getNPCScript('security_guard');
|
||||
|
||||
// Unlock
|
||||
const result = await ApiClient.unlock('door', 'office', 'password123', 'password');
|
||||
|
||||
// Update inventory
|
||||
await ApiClient.updateInventory('add', { type: 'key', name: 'Office Key' });
|
||||
|
||||
// Sync state
|
||||
await ApiClient.syncState('office', { alarm_triggered: false });
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const result = await ApiClient.unlock('door', 'office', 'wrong', 'password');
|
||||
if (!result.success) {
|
||||
console.log('Invalid password');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API error:', error);
|
||||
// Network error, server error, etc.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- All endpoints require valid Rails session
|
||||
- Uses Devise for authentication
|
||||
- Session cookies are HTTPOnly and Secure
|
||||
|
||||
### Authorization
|
||||
- Pundit policies enforce ownership
|
||||
- Players can only access their own games
|
||||
- Admins can access all games
|
||||
|
||||
### CSRF Protection
|
||||
- All POST/PUT/DELETE requests require CSRF token
|
||||
- Token embedded in game view
|
||||
- Verified by Rails on each request
|
||||
|
||||
### Data Validation
|
||||
- Unlock attempts validated server-side
|
||||
- Solutions never sent to client
|
||||
- Room data filtered before sending
|
||||
|
||||
### What's NOT Validated
|
||||
- Player position (client-side only)
|
||||
- Global variables (trusted)
|
||||
- Inventory additions (trusted)
|
||||
|
||||
**Rationale:** Balance security with simplicity. Critical game-breaking actions (unlocks) are validated. Non-critical state (position, variables) is trusted for performance.
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Detailed Logging
|
||||
|
||||
```ruby
|
||||
# config/environments/development.rb
|
||||
config.log_level = :debug
|
||||
|
||||
# View logs
|
||||
tail -f log/development.log | grep BreakEscape
|
||||
```
|
||||
|
||||
### Common Debug Points
|
||||
|
||||
```ruby
|
||||
# In controllers
|
||||
Rails.logger.debug "[BreakEscape] Game: #{@game.inspect}"
|
||||
|
||||
# In models
|
||||
Rails.logger.debug "[BreakEscape] Unlocking: #{room_id}"
|
||||
|
||||
# JIT compilation
|
||||
Rails.logger.info "[BreakEscape] Compiling #{ink_file}"
|
||||
```
|
||||
|
||||
### Test API with curl
|
||||
|
||||
```bash
|
||||
# Get CSRF token first
|
||||
TOKEN=$(curl -c cookies.txt http://localhost:3000/break_escape/games/1 | grep csrf-token | sed 's/.*content="\([^"]*\)".*/\1/')
|
||||
|
||||
# Use token in POST
|
||||
curl -X POST http://localhost:3000/break_escape/games/1/unlock \
|
||||
-b cookies.txt \
|
||||
-H "X-CSRF-Token: $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"targetType":"door","targetId":"office","attempt":"test","method":"password"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Changelog
|
||||
|
||||
### v1.0.0 (Initial Release)
|
||||
- All endpoints implemented
|
||||
- JIT Ink compilation
|
||||
- ERB scenario generation
|
||||
- Polymorphic player support
|
||||
- Session-based authentication
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check implementation plan
|
||||
- Review controller code
|
||||
- Check Rails logs
|
||||
- Refer to this API reference
|
||||
|
||||
---
|
||||
|
||||
**Complete API documentation for BreakEscape Rails Engine**
|
||||
@@ -0,0 +1,998 @@
|
||||
# Testing Guide
|
||||
|
||||
Complete testing strategy for BreakEscape Rails Engine.
|
||||
|
||||
---
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
### What We Test
|
||||
|
||||
✅ **Models** - Validations, methods, business logic
|
||||
✅ **Controllers** - HTTP responses, authorization, API contracts
|
||||
✅ **Policies** - Authorization rules
|
||||
✅ **Integration** - Full user flows end-to-end
|
||||
✅ **ERB Generation** - Scenario template processing
|
||||
✅ **JIT Compilation** - Ink compilation logic
|
||||
|
||||
### What We Don't Test
|
||||
|
||||
❌ **Client-side JavaScript** - That's Phaser's domain
|
||||
❌ **Phaser game logic** - Would require browser automation
|
||||
❌ **CSS styling** - Visual testing not in scope
|
||||
|
||||
---
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Framework:** Minitest (matches Hacktivity)
|
||||
**Style:** Test::Unit with fixtures
|
||||
**Coverage Goal:** 80%+ for critical paths
|
||||
|
||||
### Why Minitest?
|
||||
|
||||
- Matches Hacktivity's testing framework
|
||||
- Simpler than RSpec
|
||||
- Fast execution
|
||||
- Built into Rails
|
||||
- Fixture-based (good for game state)
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests
|
||||
|
||||
```bash
|
||||
# Run entire test suite
|
||||
rails test
|
||||
|
||||
# With coverage
|
||||
COVERAGE=true rails test
|
||||
```
|
||||
|
||||
### Specific Files
|
||||
|
||||
```bash
|
||||
# Run model tests
|
||||
rails test test/models/
|
||||
|
||||
# Run controller tests
|
||||
rails test test/controllers/
|
||||
|
||||
# Run specific file
|
||||
rails test test/models/break_escape/mission_test.rb
|
||||
|
||||
# Run specific test
|
||||
rails test test/models/break_escape/mission_test.rb:5
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
|
||||
```bash
|
||||
# Install guard
|
||||
gem install guard-minitest
|
||||
|
||||
# Run guard
|
||||
guard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
test/
|
||||
├── fixtures/
|
||||
│ └── break_escape/
|
||||
│ ├── missions.yml
|
||||
│ ├── games.yml
|
||||
│ └── demo_users.yml
|
||||
├── models/
|
||||
│ └── break_escape/
|
||||
│ ├── mission_test.rb
|
||||
│ └── game_test.rb
|
||||
├── controllers/
|
||||
│ └── break_escape/
|
||||
│ ├── missions_controller_test.rb
|
||||
│ ├── games_controller_test.rb
|
||||
│ └── api/
|
||||
│ └── games_controller_test.rb
|
||||
├── policies/
|
||||
│ └── break_escape/
|
||||
│ ├── mission_policy_test.rb
|
||||
│ └── game_policy_test.rb
|
||||
├── integration/
|
||||
│ └── break_escape/
|
||||
│ ├── game_flow_test.rb
|
||||
│ └── api_test.rb
|
||||
└── test_helper.rb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fixtures
|
||||
|
||||
### Mission Fixtures
|
||||
|
||||
```yaml
|
||||
# test/fixtures/break_escape/missions.yml
|
||||
ceo_exfil:
|
||||
name: ceo_exfil
|
||||
display_name: CEO Exfiltration
|
||||
description: Test scenario for CEO infiltration
|
||||
published: true
|
||||
difficulty_level: 3
|
||||
|
||||
cybok_heist:
|
||||
name: cybok_heist
|
||||
display_name: CybOK Heist
|
||||
description: Test scenario for CybOK
|
||||
published: true
|
||||
difficulty_level: 4
|
||||
|
||||
unpublished:
|
||||
name: test_unpublished
|
||||
display_name: Unpublished Test
|
||||
description: Not visible to players
|
||||
published: false
|
||||
difficulty_level: 1
|
||||
```
|
||||
|
||||
### Demo User Fixtures
|
||||
|
||||
```yaml
|
||||
# test/fixtures/break_escape/demo_users.yml
|
||||
test_user:
|
||||
handle: test_user
|
||||
role: user
|
||||
|
||||
admin_user:
|
||||
handle: admin_user
|
||||
role: admin
|
||||
|
||||
other_user:
|
||||
handle: other_user
|
||||
role: user
|
||||
```
|
||||
|
||||
### Game Fixtures
|
||||
|
||||
```yaml
|
||||
# test/fixtures/break_escape/games.yml
|
||||
active_game:
|
||||
player: test_user (BreakEscape::DemoUser)
|
||||
mission: ceo_exfil
|
||||
scenario_data:
|
||||
startRoom: reception
|
||||
rooms:
|
||||
reception:
|
||||
type: room_reception
|
||||
connections:
|
||||
north: office
|
||||
locked: false
|
||||
office:
|
||||
type: room_office
|
||||
connections:
|
||||
south: reception
|
||||
locked: true
|
||||
requires: "test_password"
|
||||
player_state:
|
||||
currentRoom: reception
|
||||
unlockedRooms:
|
||||
- reception
|
||||
unlockedObjects: []
|
||||
inventory: []
|
||||
encounteredNPCs: []
|
||||
globalVariables: {}
|
||||
biometricSamples: []
|
||||
bluetoothDevices: []
|
||||
notes: []
|
||||
health: 100
|
||||
status: in_progress
|
||||
score: 0
|
||||
|
||||
completed_game:
|
||||
player: test_user (BreakEscape::DemoUser)
|
||||
mission: cybok_heist
|
||||
scenario_data:
|
||||
startRoom: entrance
|
||||
rooms: {}
|
||||
player_state:
|
||||
currentRoom: exit
|
||||
unlockedRooms: []
|
||||
inventory: []
|
||||
health: 100
|
||||
status: completed
|
||||
score: 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Tests
|
||||
|
||||
### Mission Model Tests
|
||||
|
||||
```ruby
|
||||
# test/models/break_escape/mission_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class MissionTest < ActiveSupport::TestCase
|
||||
test "should require name" do
|
||||
mission = Mission.new(display_name: 'Test')
|
||||
assert_not mission.valid?
|
||||
assert mission.errors[:name].any?
|
||||
end
|
||||
|
||||
test "should require display_name" do
|
||||
mission = Mission.new(name: 'test')
|
||||
assert_not mission.valid?
|
||||
assert mission.errors[:display_name].any?
|
||||
end
|
||||
|
||||
test "should require unique name" do
|
||||
Mission.create!(name: 'test', display_name: 'Test')
|
||||
duplicate = Mission.new(name: 'test', display_name: 'Test 2')
|
||||
assert_not duplicate.valid?
|
||||
assert duplicate.errors[:name].include?('has already been taken')
|
||||
end
|
||||
|
||||
test "should validate difficulty_level range" do
|
||||
mission = Mission.new(name: 'test', display_name: 'Test', difficulty_level: 10)
|
||||
assert_not mission.valid?
|
||||
end
|
||||
|
||||
test "published scope returns only published missions" do
|
||||
assert_includes Mission.published, missions(:ceo_exfil)
|
||||
assert_not_includes Mission.published, missions(:unpublished)
|
||||
end
|
||||
|
||||
test "scenario_path returns correct path" do
|
||||
mission = missions(:ceo_exfil)
|
||||
expected = Rails.root.join('app/assets/scenarios/ceo_exfil')
|
||||
assert_equal expected, mission.scenario_path
|
||||
end
|
||||
|
||||
test "generate_scenario_data processes ERB and returns JSON" do
|
||||
skip "Requires actual scenario ERB file" unless File.exist?(missions(:ceo_exfil).scenario_path.join('scenario.json.erb'))
|
||||
|
||||
mission = missions(:ceo_exfil)
|
||||
scenario_data = mission.generate_scenario_data
|
||||
|
||||
assert scenario_data.is_a?(Hash)
|
||||
assert scenario_data['startRoom']
|
||||
assert scenario_data['rooms']
|
||||
|
||||
# Should not contain ERB tags
|
||||
json_string = scenario_data.to_json
|
||||
assert_not json_string.include?('<%=')
|
||||
assert_not json_string.include?('random_password')
|
||||
end
|
||||
|
||||
test "generate_scenario_data raises error for invalid JSON" do
|
||||
# Would need to create a bad ERB file to test
|
||||
skip "Requires bad scenario file"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Game Model Tests
|
||||
|
||||
```ruby
|
||||
# test/models/break_escape/game_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class GameTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@game = games(:active_game)
|
||||
end
|
||||
|
||||
test "should belong to player and mission" do
|
||||
assert @game.player
|
||||
assert_instance_of DemoUser, @game.player
|
||||
assert @game.mission
|
||||
assert_instance_of Mission, @game.mission
|
||||
end
|
||||
|
||||
test "should require player" do
|
||||
@game.player = nil
|
||||
assert_not @game.valid?
|
||||
assert @game.errors[:player].any?
|
||||
end
|
||||
|
||||
test "should require mission" do
|
||||
@game.mission = nil
|
||||
assert_not @game.valid?
|
||||
assert @game.errors[:mission].any?
|
||||
end
|
||||
|
||||
test "should validate status inclusion" do
|
||||
@game.status = 'invalid'
|
||||
assert_not @game.valid?
|
||||
assert @game.errors[:status].any?
|
||||
end
|
||||
|
||||
test "should unlock room" do
|
||||
@game.unlock_room!('office')
|
||||
assert_includes @game.player_state['unlockedRooms'], 'office'
|
||||
end
|
||||
|
||||
test "should not duplicate unlocked rooms" do
|
||||
@game.unlock_room!('office')
|
||||
@game.unlock_room!('office')
|
||||
assert_equal 1, @game.player_state['unlockedRooms'].count('office')
|
||||
end
|
||||
|
||||
test "room_unlocked? returns true for start room" do
|
||||
assert @game.room_unlocked?('reception')
|
||||
end
|
||||
|
||||
test "room_unlocked? returns true for unlocked rooms" do
|
||||
@game.unlock_room!('office')
|
||||
assert @game.room_unlocked?('office')
|
||||
end
|
||||
|
||||
test "room_unlocked? returns false for locked rooms" do
|
||||
assert_not @game.room_unlocked?('office')
|
||||
end
|
||||
|
||||
test "should unlock object" do
|
||||
@game.unlock_object!('safe_123')
|
||||
assert_includes @game.player_state['unlockedObjects'], 'safe_123'
|
||||
end
|
||||
|
||||
test "should add inventory item" do
|
||||
item = { 'type' => 'key', 'name' => 'Test Key' }
|
||||
@game.add_inventory_item!(item)
|
||||
assert_includes @game.player_state['inventory'], item
|
||||
end
|
||||
|
||||
test "should remove inventory item" do
|
||||
item = { 'id' => 'key_1', 'type' => 'key', 'name' => 'Test Key' }
|
||||
@game.add_inventory_item!(item)
|
||||
@game.remove_inventory_item!('key_1')
|
||||
assert_not_includes @game.player_state['inventory'], item
|
||||
end
|
||||
|
||||
test "should encounter NPC" do
|
||||
@game.encounter_npc!('security_guard')
|
||||
assert_includes @game.player_state['encounteredNPCs'], 'security_guard'
|
||||
end
|
||||
|
||||
test "should update global variables" do
|
||||
@game.update_global_variables!({ 'alarm' => true, 'favor' => 5 })
|
||||
assert_equal true, @game.player_state['globalVariables']['alarm']
|
||||
assert_equal 5, @game.player_state['globalVariables']['favor']
|
||||
end
|
||||
|
||||
test "should merge global variables" do
|
||||
@game.player_state['globalVariables'] = { 'existing' => 'value' }
|
||||
@game.update_global_variables!({ 'new' => 'value2' })
|
||||
assert_equal 'value', @game.player_state['globalVariables']['existing']
|
||||
assert_equal 'value2', @game.player_state['globalVariables']['new']
|
||||
end
|
||||
|
||||
test "should add biometric sample" do
|
||||
sample = { 'type' => 'fingerprint', 'data' => 'base64...' }
|
||||
@game.add_biometric_sample!(sample)
|
||||
assert_includes @game.player_state['biometricSamples'], sample
|
||||
end
|
||||
|
||||
test "should add bluetooth device" do
|
||||
device = { 'mac' => 'AA:BB:CC:DD:EE:FF', 'name' => 'Phone' }
|
||||
@game.add_bluetooth_device!(device)
|
||||
assert_includes @game.player_state['bluetoothDevices'], device
|
||||
end
|
||||
|
||||
test "should not duplicate bluetooth devices" do
|
||||
device = { 'mac' => 'AA:BB:CC:DD:EE:FF', 'name' => 'Phone' }
|
||||
@game.add_bluetooth_device!(device)
|
||||
@game.add_bluetooth_device!(device)
|
||||
assert_equal 1, @game.player_state['bluetoothDevices'].length
|
||||
end
|
||||
|
||||
test "should add note" do
|
||||
note = { 'id' => 'note_1', 'title' => 'Test', 'content' => 'Content' }
|
||||
@game.add_note!(note)
|
||||
assert_includes @game.player_state['notes'], note
|
||||
end
|
||||
|
||||
test "should update health" do
|
||||
@game.update_health!(50)
|
||||
assert_equal 50, @game.player_state['health']
|
||||
end
|
||||
|
||||
test "should clamp health to 0-100" do
|
||||
@game.update_health!(150)
|
||||
assert_equal 100, @game.player_state['health']
|
||||
|
||||
@game.update_health!(-10)
|
||||
assert_equal 0, @game.player_state['health']
|
||||
end
|
||||
|
||||
test "should get room data" do
|
||||
room_data = @game.room_data('office')
|
||||
assert_equal 'room_office', room_data['type']
|
||||
end
|
||||
|
||||
test "should filter room data" do
|
||||
room_data = @game.filtered_room_data('office')
|
||||
assert_nil room_data['requires']
|
||||
assert_nil room_data['lockType']
|
||||
end
|
||||
|
||||
test "should validate password unlock" do
|
||||
result = @game.validate_unlock('door', 'office', 'test_password', 'password')
|
||||
assert result
|
||||
end
|
||||
|
||||
test "should reject invalid password" do
|
||||
result = @game.validate_unlock('door', 'office', 'wrong', 'password')
|
||||
assert_not result
|
||||
end
|
||||
|
||||
test "should accept lockpick" do
|
||||
result = @game.validate_unlock('door', 'office', '', 'lockpick')
|
||||
assert result
|
||||
end
|
||||
|
||||
test "active scope returns in_progress games" do
|
||||
assert_includes Game.active, games(:active_game)
|
||||
assert_not_includes Game.active, games(:completed_game)
|
||||
end
|
||||
|
||||
test "completed scope returns completed games" do
|
||||
assert_includes Game.completed, games(:completed_game)
|
||||
assert_not_includes Game.completed, games(:active_game)
|
||||
end
|
||||
|
||||
test "should initialize player state on create" do
|
||||
game = Game.create!(
|
||||
player: demo_users(:test_user),
|
||||
mission: missions(:ceo_exfil)
|
||||
)
|
||||
|
||||
assert game.player_state['currentRoom']
|
||||
assert game.player_state['unlockedRooms'].include?(game.scenario_data['startRoom'])
|
||||
assert_equal 100, game.player_state['health']
|
||||
end
|
||||
|
||||
test "should generate scenario data on create" do
|
||||
game = Game.create!(
|
||||
player: demo_users(:test_user),
|
||||
mission: missions(:cybok_heist)
|
||||
)
|
||||
|
||||
assert game.scenario_data
|
||||
assert game.scenario_data['startRoom']
|
||||
assert game.scenario_data['rooms']
|
||||
end
|
||||
|
||||
test "should set started_at on create" do
|
||||
game = Game.create!(
|
||||
player: demo_users(:test_user),
|
||||
mission: missions(:ceo_exfil)
|
||||
)
|
||||
|
||||
assert game.started_at
|
||||
assert game.started_at <= Time.current
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controller Tests
|
||||
|
||||
### Missions Controller Tests
|
||||
|
||||
```ruby
|
||||
# test/controllers/break_escape/missions_controller_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class MissionsControllerTest < ActionDispatch::IntegrationTest
|
||||
include Engine.routes.url_helpers
|
||||
|
||||
test "should get index" do
|
||||
get missions_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "index should show published missions" do
|
||||
get missions_url
|
||||
assert_response :success
|
||||
# Would need to parse HTML to verify, or use system tests
|
||||
end
|
||||
|
||||
test "should redirect to game when showing mission" do
|
||||
mission = missions(:ceo_exfil)
|
||||
|
||||
# Simulate being logged in (would use Devise helpers in real app)
|
||||
# For now, testing with standalone mode
|
||||
get mission_url(mission)
|
||||
|
||||
assert_response :redirect
|
||||
assert_match /games\/\d+/, @response.location
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Games Controller Tests
|
||||
|
||||
```ruby
|
||||
# test/controllers/break_escape/games_controller_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class GamesControllerTest < ActionDispatch::IntegrationTest
|
||||
include Engine.routes.url_helpers
|
||||
|
||||
setup do
|
||||
@game = games(:active_game)
|
||||
end
|
||||
|
||||
test "should get show" do
|
||||
# Would need authentication setup
|
||||
get game_url(@game)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should get scenario JSON" do
|
||||
get scenario_game_url(@game), as: :json
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert json['startRoom']
|
||||
assert json['rooms']
|
||||
end
|
||||
|
||||
test "should get ink script" do
|
||||
skip "Requires ink file setup"
|
||||
|
||||
get ink_game_url(@game, npc: 'security_guard'), as: :json
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert json.is_a?(Hash)
|
||||
end
|
||||
|
||||
test "ink endpoint should return 400 without npc parameter" do
|
||||
get ink_game_url(@game), as: :json
|
||||
assert_response :bad_request
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert_equal 'Missing npc parameter', json['error']
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### API Games Controller Tests
|
||||
|
||||
```ruby
|
||||
# test/controllers/break_escape/api/games_controller_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
module Api
|
||||
class GamesControllerTest < ActionDispatch::IntegrationTest
|
||||
include Engine.routes.url_helpers
|
||||
|
||||
setup do
|
||||
@game = games(:active_game)
|
||||
end
|
||||
|
||||
test "should get bootstrap" do
|
||||
get bootstrap_game_url(@game), as: :json
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert_equal @game.id, json['gameId']
|
||||
assert json['missionName']
|
||||
assert json['startRoom']
|
||||
assert json['playerState']
|
||||
assert json['roomLayout']
|
||||
end
|
||||
|
||||
test "should sync state" do
|
||||
put sync_state_game_url(@game), params: {
|
||||
currentRoom: 'office',
|
||||
globalVariables: { alarm: true }
|
||||
}, as: :json
|
||||
|
||||
assert_response :success
|
||||
|
||||
@game.reload
|
||||
assert_equal 'office', @game.player_state['currentRoom']
|
||||
assert_equal true, @game.player_state['globalVariables']['alarm']
|
||||
end
|
||||
|
||||
test "should validate unlock with correct password" do
|
||||
post unlock_game_url(@game), params: {
|
||||
targetType: 'door',
|
||||
targetId: 'office',
|
||||
attempt: 'test_password',
|
||||
method: 'password'
|
||||
}, as: :json
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert json['success']
|
||||
assert_equal 'door', json['type']
|
||||
assert json['roomData']
|
||||
end
|
||||
|
||||
test "should reject unlock with wrong password" do
|
||||
post unlock_game_url(@game), params: {
|
||||
targetType: 'door',
|
||||
targetId: 'office',
|
||||
attempt: 'wrong',
|
||||
method: 'password'
|
||||
}, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert_not json['success']
|
||||
end
|
||||
|
||||
test "should add inventory item" do
|
||||
post inventory_game_url(@game), params: {
|
||||
action: 'add',
|
||||
item: { type: 'key', name: 'Test Key' }
|
||||
}, as: :json
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert json['success']
|
||||
assert json['inventory'].any? { |i| i['name'] == 'Test Key' }
|
||||
end
|
||||
|
||||
test "should remove inventory item" do
|
||||
@game.add_inventory_item!({ 'id' => 'key_1', 'type' => 'key' })
|
||||
|
||||
post inventory_game_url(@game), params: {
|
||||
action: 'remove',
|
||||
item: { id: 'key_1' }
|
||||
}, as: :json
|
||||
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(@response.body)
|
||||
assert json['success']
|
||||
assert_not json['inventory'].any? { |i| i['id'] == 'key_1' }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Policy Tests
|
||||
|
||||
```ruby
|
||||
# test/policies/break_escape/game_policy_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class GamePolicyTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = demo_users(:test_user)
|
||||
@admin = demo_users(:admin_user)
|
||||
@other_user = demo_users(:other_user)
|
||||
@game = games(:active_game)
|
||||
end
|
||||
|
||||
test "owner can show game" do
|
||||
policy = GamePolicy.new(@user, @game)
|
||||
assert policy.show?
|
||||
end
|
||||
|
||||
test "other user cannot show game" do
|
||||
policy = GamePolicy.new(@other_user, @game)
|
||||
assert_not policy.show?
|
||||
end
|
||||
|
||||
test "admin can show any game" do
|
||||
policy = GamePolicy.new(@admin, @game)
|
||||
assert policy.show?
|
||||
end
|
||||
|
||||
test "scope returns user's games" do
|
||||
scope = GamePolicy::Scope.new(@user, Game).resolve
|
||||
assert_includes scope, @game
|
||||
end
|
||||
|
||||
test "scope returns all games for admin" do
|
||||
scope = GamePolicy::Scope.new(@admin, Game).resolve
|
||||
assert_equal Game.count, scope.count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# test/policies/break_escape/mission_policy_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class MissionPolicyTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = demo_users(:test_user)
|
||||
@admin = demo_users(:admin_user)
|
||||
@published = missions(:ceo_exfil)
|
||||
@unpublished = missions(:unpublished)
|
||||
end
|
||||
|
||||
test "anyone can view index" do
|
||||
policy = MissionPolicy.new(@user, Mission)
|
||||
assert policy.index?
|
||||
end
|
||||
|
||||
test "anyone can view published mission" do
|
||||
policy = MissionPolicy.new(@user, @published)
|
||||
assert policy.show?
|
||||
end
|
||||
|
||||
test "user cannot view unpublished mission" do
|
||||
policy = MissionPolicy.new(@user, @unpublished)
|
||||
assert_not policy.show?
|
||||
end
|
||||
|
||||
test "admin can view unpublished mission" do
|
||||
policy = MissionPolicy.new(@admin, @unpublished)
|
||||
assert policy.show?
|
||||
end
|
||||
|
||||
test "scope returns only published for users" do
|
||||
scope = MissionPolicy::Scope.new(@user, Mission).resolve
|
||||
assert_includes scope, @published
|
||||
assert_not_includes scope, @unpublished
|
||||
end
|
||||
|
||||
test "scope returns all missions for admin" do
|
||||
scope = MissionPolicy::Scope.new(@admin, Mission).resolve
|
||||
assert_equal Mission.count, scope.count
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests
|
||||
|
||||
```ruby
|
||||
# test/integration/break_escape/game_flow_test.rb
|
||||
require 'test_helper'
|
||||
|
||||
module BreakEscape
|
||||
class GameFlowTest < ActionDispatch::IntegrationTest
|
||||
include Engine.routes.url_helpers
|
||||
|
||||
test "complete game flow" do
|
||||
# 1. Visit mission list
|
||||
get missions_url
|
||||
assert_response :success
|
||||
|
||||
# 2. Select mission (redirects to game)
|
||||
mission = missions(:ceo_exfil)
|
||||
get mission_url(mission)
|
||||
assert_response :redirect
|
||||
follow_redirect!
|
||||
|
||||
# Extract game ID from redirect
|
||||
game_id = @response.location.match(/games\/(\d+)/)[1]
|
||||
game = Game.find(game_id)
|
||||
|
||||
# 3. Bootstrap game
|
||||
get bootstrap_game_url(game), as: :json
|
||||
assert_response :success
|
||||
bootstrap_data = JSON.parse(@response.body)
|
||||
assert_equal game.id, bootstrap_data['gameId']
|
||||
|
||||
# 4. Get scenario
|
||||
get scenario_game_url(game), as: :json
|
||||
assert_response :success
|
||||
scenario = JSON.parse(@response.body)
|
||||
assert scenario['rooms']
|
||||
|
||||
# 5. Attempt unlock
|
||||
post unlock_game_url(game), params: {
|
||||
targetType: 'door',
|
||||
targetId: 'office',
|
||||
attempt: 'test_password',
|
||||
method: 'password'
|
||||
}, as: :json
|
||||
assert_response :success
|
||||
unlock_result = JSON.parse(@response.body)
|
||||
assert unlock_result['success']
|
||||
|
||||
# 6. Sync state
|
||||
put sync_state_game_url(game), params: {
|
||||
currentRoom: 'office',
|
||||
globalVariables: { progress: 50 }
|
||||
}, as: :json
|
||||
assert_response :success
|
||||
|
||||
# 7. Verify state persisted
|
||||
game.reload
|
||||
assert_equal 'office', game.player_state['currentRoom']
|
||||
assert game.room_unlocked?('office')
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Helpers
|
||||
|
||||
```ruby
|
||||
# test/test_helper.rb
|
||||
ENV['RAILS_ENV'] ||= 'test'
|
||||
require_relative "../config/environment"
|
||||
require "rails/test_help"
|
||||
|
||||
class ActiveSupport::TestCase
|
||||
# Run tests in parallel with specified workers
|
||||
parallelize(workers: :number_of_processors)
|
||||
|
||||
# Setup all fixtures in test/fixtures/*.yml
|
||||
fixtures :all
|
||||
|
||||
# Add more helper methods to be used by all tests here...
|
||||
|
||||
def json_response
|
||||
JSON.parse(@response.body)
|
||||
end
|
||||
|
||||
# Simulate standalone mode
|
||||
def enable_standalone_mode
|
||||
BreakEscape.configuration.standalone_mode = true
|
||||
end
|
||||
|
||||
def disable_standalone_mode
|
||||
BreakEscape.configuration.standalone_mode = false
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage
|
||||
|
||||
### Setup SimpleCov
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
group :test do
|
||||
gem 'simplecov', require: false
|
||||
end
|
||||
|
||||
# test/test_helper.rb (at the very top)
|
||||
if ENV['COVERAGE']
|
||||
require 'simplecov'
|
||||
SimpleCov.start 'rails' do
|
||||
add_filter '/test/'
|
||||
add_filter '/config/'
|
||||
add_filter '/vendor/'
|
||||
|
||||
add_group 'Models', 'app/models'
|
||||
add_group 'Controllers', 'app/controllers'
|
||||
add_group 'Policies', 'app/policies'
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Run with Coverage
|
||||
|
||||
```bash
|
||||
COVERAGE=true rails test
|
||||
open coverage/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.0
|
||||
bundler-cache: true
|
||||
|
||||
- name: Setup database
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/break_escape_test
|
||||
run: |
|
||||
bundle exec rails db:create
|
||||
bundle exec rails db:migrate
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/break_escape_test
|
||||
run: bundle exec rails test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do
|
||||
|
||||
✅ Test one thing per test
|
||||
✅ Use descriptive test names
|
||||
✅ Use fixtures for game state
|
||||
✅ Test both success and failure cases
|
||||
✅ Test edge cases (empty inventory, max health, etc.)
|
||||
✅ Test authorization (who can access what)
|
||||
✅ Use setup/teardown for common setup
|
||||
✅ Mock external dependencies if any
|
||||
|
||||
### Don't
|
||||
|
||||
❌ Test framework internals (Rails, Phaser)
|
||||
❌ Test CSS or JavaScript (that's system test territory)
|
||||
❌ Write flaky tests (time-dependent, order-dependent)
|
||||
❌ Test implementation details
|
||||
❌ Duplicate tests
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Test Coverage:**
|
||||
- ✅ 2 models (Mission, Game)
|
||||
- ✅ 3 controllers (Missions, Games, API::Games)
|
||||
- ✅ 2 policies (Mission, Game)
|
||||
- ✅ Integration tests for full flow
|
||||
- ✅ Fixtures for all models
|
||||
- ✅ CI/CD ready
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
rails test
|
||||
```
|
||||
|
||||
**With coverage:**
|
||||
```bash
|
||||
COVERAGE=true rails test
|
||||
```
|
||||
|
||||
All tests should pass before merging to main!
|
||||
Reference in New Issue
Block a user