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:
462
api/tests/test_inventory_api.py
Normal file
462
api/tests/test_inventory_api.py
Normal 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
|
||||
Reference in New Issue
Block a user