feat(api): implement inventory API endpoints

Add REST API endpoints for character inventory management:
- GET /api/v1/characters/<id>/inventory - Get inventory and equipped items
- POST /api/v1/characters/<id>/inventory/equip - Equip item to slot
- POST /api/v1/characters/<id>/inventory/unequip - Unequip from slot
- POST /api/v1/characters/<id>/inventory/use - Use consumable item
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop item

All endpoints include:
- Authentication via @require_auth decorator
- Ownership validation through CharacterService
- Comprehensive error handling with proper HTTP status codes
- Full logging for debugging

Includes 25 integration tests covering authentication requirements,
URL patterns, and response formats.

Task 2.4 of Phase 4 Combat Implementation complete.
This commit is contained in:
2025-11-26 18:54:33 -06:00
parent 76f67c4a22
commit 4ced1b04df
5 changed files with 1157 additions and 148 deletions

View File

@@ -0,0 +1,462 @@
"""
Integration tests for Inventory API endpoints.
Tests the REST API endpoints for inventory management functionality.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from flask import Flask
import json
from app import create_app
from app.api.inventory import inventory_bp
from app.models.items import Item
from app.models.character import Character
from app.models.stats import Stats
from app.models.skills import PlayerClass
from app.models.origins import Origin
from app.models.enums import ItemType, ItemRarity, DamageType
from app.services.inventory_service import (
InventoryService,
ItemNotFoundError,
CannotEquipError,
InvalidSlotError,
CannotUseItemError,
InventoryFullError,
VALID_SLOTS,
)
from app.services.character_service import CharacterNotFound
# =============================================================================
# Test Fixtures
# =============================================================================
@pytest.fixture
def app():
"""Create test Flask application."""
app = create_app('development')
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
@pytest.fixture
def sample_stats():
"""Sample stats for testing."""
return Stats(
strength=12,
dexterity=14,
constitution=10,
intelligence=10,
wisdom=10,
charisma=10,
luck=10
)
@pytest.fixture
def sample_weapon():
"""Sample weapon item."""
return Item(
item_id="test_sword_001",
name="Iron Sword",
item_type=ItemType.WEAPON,
rarity=ItemRarity.COMMON,
description="A sturdy iron sword",
value=50,
damage=8,
damage_type=DamageType.PHYSICAL,
crit_chance=0.05,
crit_multiplier=2.0,
required_level=1,
)
@pytest.fixture
def sample_armor():
"""Sample armor item."""
return Item(
item_id="test_helmet_001",
name="Iron Helmet",
item_type=ItemType.ARMOR,
rarity=ItemRarity.COMMON,
description="A sturdy iron helmet",
value=30,
defense=5,
resistance=2,
required_level=1,
)
@pytest.fixture
def sample_consumable():
"""Sample consumable item."""
return Item(
item_id="health_potion_small",
name="Small Health Potion",
item_type=ItemType.CONSUMABLE,
rarity=ItemRarity.COMMON,
description="Restores a small amount of health",
value=25,
effects_on_use=[], # Simplified for testing
)
@pytest.fixture
def sample_class():
"""Sample player class."""
return PlayerClass(
class_id="vanguard",
name="Vanguard",
description="A heavily armored warrior",
base_stats=Stats(
strength=14,
dexterity=10,
constitution=14,
intelligence=8,
wisdom=8,
charisma=10,
luck=10
),
skill_trees=[],
starting_equipment=[],
starting_abilities=[],
)
@pytest.fixture
def sample_origin():
"""Sample origin."""
return Origin(
id="soul_revenant",
name="Soul Revenant",
description="Returned from death",
starting_location={"area": "graveyard", "name": "Graveyard"},
narrative_hooks=[],
starting_bonus={},
)
@pytest.fixture
def sample_character(sample_class, sample_origin, sample_weapon, sample_armor, sample_consumable):
"""Sample character with inventory."""
char = Character(
character_id="test_char_001",
user_id="test_user_001",
name="Test Hero",
player_class=sample_class,
origin=sample_origin,
level=5,
experience=0,
gold=100,
inventory=[sample_weapon, sample_armor, sample_consumable],
equipped={},
unlocked_skills=[],
)
return char
# =============================================================================
# GET Inventory Endpoint Tests
# =============================================================================
class TestGetInventoryEndpoint:
"""Tests for GET /api/v1/characters/<id>/inventory endpoint."""
def test_get_inventory_requires_auth(self, client):
"""Test that inventory endpoint requires authentication."""
response = client.get('/api/v1/characters/test_char_001/inventory')
# Should return 401 Unauthorized without valid session
assert response.status_code == 401
def test_get_inventory_character_not_found(self, client):
"""Test getting inventory for non-existent character returns 404 (after auth)."""
# Without auth, returns 401 regardless
response = client.get('/api/v1/characters/nonexistent_12345/inventory')
assert response.status_code == 401
# =============================================================================
# POST Equip Endpoint Tests
# =============================================================================
class TestEquipEndpoint:
"""Tests for POST /api/v1/characters/<id>/inventory/equip endpoint."""
def test_equip_requires_auth(self, client):
"""Test that equip endpoint requires authentication."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/equip',
json={
'item_id': 'test_sword_001',
'slot': 'weapon'
}
)
# Should return 401 Unauthorized without valid session
assert response.status_code == 401
def test_equip_missing_item_id(self, client):
"""Test equip without item_id still requires auth first."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/equip',
json={'slot': 'weapon'}
)
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
def test_equip_missing_slot(self, client):
"""Test equip without slot still requires auth first."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/equip',
json={'item_id': 'test_sword_001'}
)
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
def test_equip_missing_body(self, client):
"""Test equip without request body still requires auth first."""
response = client.post('/api/v1/characters/test_char_001/inventory/equip')
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
# =============================================================================
# POST Unequip Endpoint Tests
# =============================================================================
class TestUnequipEndpoint:
"""Tests for POST /api/v1/characters/<id>/inventory/unequip endpoint."""
def test_unequip_requires_auth(self, client):
"""Test that unequip endpoint requires authentication."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/unequip',
json={'slot': 'weapon'}
)
# Should return 401 Unauthorized without valid session
assert response.status_code == 401
def test_unequip_missing_slot(self, client):
"""Test unequip without slot still requires auth first."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/unequip',
json={}
)
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
def test_unequip_missing_body(self, client):
"""Test unequip without request body still requires auth first."""
response = client.post('/api/v1/characters/test_char_001/inventory/unequip')
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
# =============================================================================
# POST Use Item Endpoint Tests
# =============================================================================
class TestUseItemEndpoint:
"""Tests for POST /api/v1/characters/<id>/inventory/use endpoint."""
def test_use_requires_auth(self, client):
"""Test that use item endpoint requires authentication."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/use',
json={'item_id': 'health_potion_small'}
)
# Should return 401 Unauthorized without valid session
assert response.status_code == 401
def test_use_missing_item_id(self, client):
"""Test use item without item_id still requires auth first."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/use',
json={}
)
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
def test_use_missing_body(self, client):
"""Test use item without request body still requires auth first."""
response = client.post('/api/v1/characters/test_char_001/inventory/use')
# Without auth, returns 401 regardless of payload issues
assert response.status_code == 401
# =============================================================================
# DELETE Drop Item Endpoint Tests
# =============================================================================
class TestDropItemEndpoint:
"""Tests for DELETE /api/v1/characters/<id>/inventory/<item_id> endpoint."""
def test_drop_requires_auth(self, client):
"""Test that drop item endpoint requires authentication."""
response = client.delete(
'/api/v1/characters/test_char_001/inventory/test_sword_001'
)
# Should return 401 Unauthorized without valid session
assert response.status_code == 401
# =============================================================================
# Valid Slot Tests (Unit level)
# =============================================================================
class TestValidSlots:
"""Tests to verify slot configuration."""
def test_valid_slots_defined(self):
"""Test that all expected slots are defined."""
expected_slots = {
'weapon', 'off_hand', 'helmet', 'chest',
'gloves', 'boots', 'accessory_1', 'accessory_2'
}
assert VALID_SLOTS == expected_slots
def test_valid_slots_count(self):
"""Test that we have exactly 8 equipment slots."""
assert len(VALID_SLOTS) == 8
# =============================================================================
# Endpoint URL Pattern Tests
# =============================================================================
class TestEndpointURLPatterns:
"""Tests to verify correct URL patterns."""
def test_get_inventory_url(self, client):
"""Test GET inventory URL pattern."""
response = client.get('/api/v1/characters/any_id/inventory')
# Should be 401 (auth required), not 404 (route not found)
assert response.status_code == 401
def test_equip_url(self, client):
"""Test POST equip URL pattern."""
response = client.post(
'/api/v1/characters/any_id/inventory/equip',
json={'item_id': 'x', 'slot': 'weapon'}
)
# Should be 401 (auth required), not 404 (route not found)
assert response.status_code == 401
def test_unequip_url(self, client):
"""Test POST unequip URL pattern."""
response = client.post(
'/api/v1/characters/any_id/inventory/unequip',
json={'slot': 'weapon'}
)
# Should be 401 (auth required), not 404 (route not found)
assert response.status_code == 401
def test_use_url(self, client):
"""Test POST use URL pattern."""
response = client.post(
'/api/v1/characters/any_id/inventory/use',
json={'item_id': 'x'}
)
# Should be 401 (auth required), not 404 (route not found)
assert response.status_code == 401
def test_drop_url(self, client):
"""Test DELETE drop URL pattern."""
response = client.delete('/api/v1/characters/any_id/inventory/item_123')
# Should be 401 (auth required), not 404 (route not found)
assert response.status_code == 401
# =============================================================================
# Response Format Tests (verifying blueprint registration)
# =============================================================================
class TestResponseFormats:
"""Tests to verify API response format consistency."""
def test_get_inventory_401_format(self, client):
"""Test that 401 response follows standard format."""
response = client.get('/api/v1/characters/test_char_001/inventory')
assert response.status_code == 401
data = response.get_json()
# Standard response format should include status
assert data is not None
assert 'status' in data
assert data['status'] == 401
def test_equip_401_format(self, client):
"""Test that equip 401 response follows standard format."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/equip',
json={'item_id': 'test', 'slot': 'weapon'}
)
assert response.status_code == 401
data = response.get_json()
assert data is not None
assert 'status' in data
assert data['status'] == 401
def test_unequip_401_format(self, client):
"""Test that unequip 401 response follows standard format."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/unequip',
json={'slot': 'weapon'}
)
assert response.status_code == 401
data = response.get_json()
assert data is not None
assert 'status' in data
assert data['status'] == 401
def test_use_401_format(self, client):
"""Test that use 401 response follows standard format."""
response = client.post(
'/api/v1/characters/test_char_001/inventory/use',
json={'item_id': 'test'}
)
assert response.status_code == 401
data = response.get_json()
assert data is not None
assert 'status' in data
assert data['status'] == 401
def test_drop_401_format(self, client):
"""Test that drop 401 response follows standard format."""
response = client.delete(
'/api/v1/characters/test_char_001/inventory/test_item'
)
assert response.status_code == 401
data = response.get_json()
assert data is not None
assert 'status' in data
assert data['status'] == 401