diff --git a/planning_notes/rails-engine-migration-simplified/04_API_REFERENCE.md b/planning_notes/rails-engine-migration-simplified/04_API_REFERENCE.md new file mode 100644 index 0000000..2484d6b --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/04_API_REFERENCE.md @@ -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: # 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 + + +``` + +**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 + + + + + Mission Name - BreakEscape + + + +
+ + + + +``` + +**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=` + +**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** diff --git a/planning_notes/rails-engine-migration-simplified/05_TESTING_GUIDE.md b/planning_notes/rails-engine-migration-simplified/05_TESTING_GUIDE.md new file mode 100644 index 0000000..11f6bab --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/05_TESTING_GUIDE.md @@ -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!