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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ Thumbs.db
|
|||||||
logs/
|
logs/
|
||||||
app/logs/
|
app/logs/
|
||||||
*.log
|
*.log
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(combat_bp)
|
app.register_blueprint(combat_bp)
|
||||||
logger.info("Combat API blueprint registered")
|
logger.info("Combat API blueprint registered")
|
||||||
|
|
||||||
|
# Import and register Inventory API blueprint
|
||||||
|
from app.api.inventory import inventory_bp
|
||||||
|
app.register_blueprint(inventory_bp)
|
||||||
|
logger.info("Inventory API blueprint registered")
|
||||||
|
|
||||||
# TODO: Register additional blueprints as they are created
|
# TODO: Register additional blueprints as they are created
|
||||||
# from app.api import marketplace, shop
|
# from app.api import marketplace, shop
|
||||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||||
|
|||||||
639
api/app/api/inventory.py
Normal file
639
api/app/api/inventory.py
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
"""
|
||||||
|
Inventory API Blueprint
|
||||||
|
|
||||||
|
Endpoints for managing character inventory and equipment.
|
||||||
|
All endpoints require authentication and enforce ownership validation.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /api/v1/characters/<id>/inventory - Get character inventory and equipped items
|
||||||
|
- POST /api/v1/characters/<id>/inventory/equip - Equip an item
|
||||||
|
- POST /api/v1/characters/<id>/inventory/unequip - Unequip an item
|
||||||
|
- POST /api/v1/characters/<id>/inventory/use - Use a consumable item
|
||||||
|
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop an item
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from app.services.inventory_service import (
|
||||||
|
get_inventory_service,
|
||||||
|
ItemNotFoundError,
|
||||||
|
CannotEquipError,
|
||||||
|
InvalidSlotError,
|
||||||
|
CannotUseItemError,
|
||||||
|
InventoryFullError,
|
||||||
|
VALID_SLOTS,
|
||||||
|
MAX_INVENTORY_SIZE,
|
||||||
|
)
|
||||||
|
from app.services.character_service import (
|
||||||
|
get_character_service,
|
||||||
|
CharacterNotFound,
|
||||||
|
)
|
||||||
|
from app.utils.response import (
|
||||||
|
success_response,
|
||||||
|
error_response,
|
||||||
|
not_found_response,
|
||||||
|
validation_error_response,
|
||||||
|
)
|
||||||
|
from app.utils.auth import require_auth, get_current_user
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize logger
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
inventory_bp = Blueprint('inventory', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@inventory_bp.route('/api/v1/characters/<character_id>/inventory', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_inventory(character_id: str):
|
||||||
|
"""
|
||||||
|
Get character inventory and equipped items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Inventory and equipment data
|
||||||
|
401: Not authenticated
|
||||||
|
404: Character not found or not owned by user
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"item_id": "gen_abc123",
|
||||||
|
"name": "Flaming Dagger",
|
||||||
|
"item_type": "weapon",
|
||||||
|
"rarity": "rare",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"equipped": {
|
||||||
|
"weapon": {...},
|
||||||
|
"helmet": null,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"inventory_count": 5,
|
||||||
|
"max_inventory": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
logger.info("Getting inventory",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Get inventory service
|
||||||
|
inventory_service = get_inventory_service()
|
||||||
|
|
||||||
|
# Get inventory items
|
||||||
|
inventory_items = inventory_service.get_inventory(character)
|
||||||
|
|
||||||
|
# Get equipped items
|
||||||
|
equipped_items = inventory_service.get_equipped_items(character)
|
||||||
|
|
||||||
|
# Build equipped dict with all slots (None for empty slots)
|
||||||
|
equipped_response = {}
|
||||||
|
for slot in VALID_SLOTS:
|
||||||
|
item = equipped_items.get(slot)
|
||||||
|
equipped_response[slot] = item.to_dict() if item else None
|
||||||
|
|
||||||
|
logger.info("Inventory retrieved successfully",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_count=len(inventory_items))
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
result={
|
||||||
|
"inventory": [item.to_dict() for item in inventory_items],
|
||||||
|
"equipped": equipped_response,
|
||||||
|
"inventory_count": len(inventory_items),
|
||||||
|
"max_inventory": MAX_INVENTORY_SIZE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning("Character not found",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get inventory",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="INVENTORY_GET_ERROR",
|
||||||
|
message="Failed to retrieve inventory",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/equip', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def equip_item(character_id: str):
|
||||||
|
"""
|
||||||
|
Equip an item from inventory to a specified slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"item_id": "gen_abc123",
|
||||||
|
"slot": "weapon"
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Item equipped successfully
|
||||||
|
400: Cannot equip item (wrong type, level requirement, etc.)
|
||||||
|
401: Not authenticated
|
||||||
|
404: Character or item not found
|
||||||
|
422: Validation error (invalid slot)
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"message": "Equipped Flaming Dagger to weapon slot",
|
||||||
|
"equipped": {...},
|
||||||
|
"unequipped_item": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Request body is required",
|
||||||
|
details={"error": "Request body is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
item_id = data.get('item_id', '').strip()
|
||||||
|
slot = data.get('slot', '').strip().lower()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validation_errors = {}
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
validation_errors['item_id'] = "item_id is required"
|
||||||
|
|
||||||
|
if not slot:
|
||||||
|
validation_errors['slot'] = "slot is required"
|
||||||
|
|
||||||
|
if validation_errors:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Validation failed",
|
||||||
|
details=validation_errors
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Equipping item",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
slot=slot)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Get inventory service
|
||||||
|
inventory_service = get_inventory_service()
|
||||||
|
|
||||||
|
# Equip item
|
||||||
|
previous_item = inventory_service.equip_item(
|
||||||
|
character, item_id, slot, user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get item name for message
|
||||||
|
equipped_item = character.equipped.get(slot)
|
||||||
|
item_name = equipped_item.get_display_name() if equipped_item else item_id
|
||||||
|
|
||||||
|
# Build equipped response
|
||||||
|
equipped_response = {}
|
||||||
|
for s in VALID_SLOTS:
|
||||||
|
item = character.equipped.get(s)
|
||||||
|
equipped_response[s] = item.to_dict() if item else None
|
||||||
|
|
||||||
|
logger.info("Item equipped successfully",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
slot=slot,
|
||||||
|
previous_item=previous_item.item_id if previous_item else None)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
result={
|
||||||
|
"message": f"Equipped {item_name} to {slot} slot",
|
||||||
|
"equipped": equipped_response,
|
||||||
|
"unequipped_item": previous_item.to_dict() if previous_item else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning("Character not found for equip",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ItemNotFoundError as e:
|
||||||
|
logger.warning("Item not found for equip",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except InvalidSlotError as e:
|
||||||
|
logger.warning("Invalid slot for equip",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
slot=slot if 'slot' in locals() else 'unknown',
|
||||||
|
error=str(e))
|
||||||
|
return validation_error_response(
|
||||||
|
message=str(e),
|
||||||
|
details={"slot": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except CannotEquipError as e:
|
||||||
|
logger.warning("Cannot equip item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="CANNOT_EQUIP",
|
||||||
|
message=str(e),
|
||||||
|
status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to equip item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="EQUIP_ERROR",
|
||||||
|
message="Failed to equip item",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/unequip', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def unequip_item(character_id: str):
|
||||||
|
"""
|
||||||
|
Unequip an item from a specified slot (returns to inventory).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"slot": "weapon"
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Item unequipped successfully (or slot was empty)
|
||||||
|
400: Inventory full, cannot unequip
|
||||||
|
401: Not authenticated
|
||||||
|
404: Character not found
|
||||||
|
422: Validation error (invalid slot)
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"message": "Unequipped Flaming Dagger from weapon slot",
|
||||||
|
"unequipped_item": {...},
|
||||||
|
"equipped": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Request body is required",
|
||||||
|
details={"error": "Request body is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
slot = data.get('slot', '').strip().lower()
|
||||||
|
|
||||||
|
if not slot:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Validation failed",
|
||||||
|
details={"slot": "slot is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Unequipping item",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
slot=slot)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Get inventory service
|
||||||
|
inventory_service = get_inventory_service()
|
||||||
|
|
||||||
|
# Unequip item
|
||||||
|
unequipped_item = inventory_service.unequip_item(character, slot, user.id)
|
||||||
|
|
||||||
|
# Build equipped response
|
||||||
|
equipped_response = {}
|
||||||
|
for s in VALID_SLOTS:
|
||||||
|
item = character.equipped.get(s)
|
||||||
|
equipped_response[s] = item.to_dict() if item else None
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
if unequipped_item:
|
||||||
|
message = f"Unequipped {unequipped_item.get_display_name()} from {slot} slot"
|
||||||
|
else:
|
||||||
|
message = f"Slot '{slot}' was already empty"
|
||||||
|
|
||||||
|
logger.info("Item unequipped",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
slot=slot,
|
||||||
|
unequipped_item=unequipped_item.item_id if unequipped_item else None)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
result={
|
||||||
|
"message": message,
|
||||||
|
"unequipped_item": unequipped_item.to_dict() if unequipped_item else None,
|
||||||
|
"equipped": equipped_response,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning("Character not found for unequip",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except InvalidSlotError as e:
|
||||||
|
logger.warning("Invalid slot for unequip",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
slot=slot if 'slot' in locals() else 'unknown',
|
||||||
|
error=str(e))
|
||||||
|
return validation_error_response(
|
||||||
|
message=str(e),
|
||||||
|
details={"slot": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except InventoryFullError as e:
|
||||||
|
logger.warning("Inventory full, cannot unequip",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="INVENTORY_FULL",
|
||||||
|
message=str(e),
|
||||||
|
status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to unequip item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="UNEQUIP_ERROR",
|
||||||
|
message="Failed to unequip item",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/use', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def use_item(character_id: str):
|
||||||
|
"""
|
||||||
|
Use a consumable item from inventory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"item_id": "health_potion_small"
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Item used successfully
|
||||||
|
400: Cannot use item (not consumable)
|
||||||
|
401: Not authenticated
|
||||||
|
404: Character or item not found
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"item_used": "Small Health Potion",
|
||||||
|
"effects_applied": [
|
||||||
|
{
|
||||||
|
"effect_name": "Healing",
|
||||||
|
"effect_type": "hot",
|
||||||
|
"value": 25,
|
||||||
|
"message": "Restored 25 HP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hp_restored": 25,
|
||||||
|
"mp_restored": 0,
|
||||||
|
"message": "Used Small Health Potion: Restored 25 HP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Request body is required",
|
||||||
|
details={"error": "Request body is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
item_id = data.get('item_id', '').strip()
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Validation failed",
|
||||||
|
details={"item_id": "item_id is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Using item",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Get inventory service
|
||||||
|
inventory_service = get_inventory_service()
|
||||||
|
|
||||||
|
# Use consumable
|
||||||
|
result = inventory_service.use_consumable(character, item_id, user.id)
|
||||||
|
|
||||||
|
logger.info("Item used successfully",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
hp_restored=result.hp_restored,
|
||||||
|
mp_restored=result.mp_restored)
|
||||||
|
|
||||||
|
return success_response(result=result.to_dict())
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning("Character not found for use item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ItemNotFoundError as e:
|
||||||
|
logger.warning("Item not found for use",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except CannotUseItemError as e:
|
||||||
|
logger.warning("Cannot use item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="CANNOT_USE_ITEM",
|
||||||
|
message=str(e),
|
||||||
|
status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to use item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="USE_ITEM_ERROR",
|
||||||
|
message="Failed to use item",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/<item_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def drop_item(character_id: str, item_id: str):
|
||||||
|
"""
|
||||||
|
Drop (remove) an item from inventory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
item_id: Item ID to drop
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Item dropped successfully
|
||||||
|
401: Not authenticated
|
||||||
|
404: Character or item not found
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"message": "Dropped Rusty Sword",
|
||||||
|
"dropped_item": {...},
|
||||||
|
"inventory_count": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
logger.info("Dropping item",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Get inventory service
|
||||||
|
inventory_service = get_inventory_service()
|
||||||
|
|
||||||
|
# Drop item
|
||||||
|
dropped_item = inventory_service.drop_item(character, item_id, user.id)
|
||||||
|
|
||||||
|
logger.info("Item dropped successfully",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
item_name=dropped_item.get_display_name())
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
result={
|
||||||
|
"message": f"Dropped {dropped_item.get_display_name()}",
|
||||||
|
"dropped_item": dropped_item.to_dict(),
|
||||||
|
"inventory_count": len(character.inventory),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning("Character not found for drop",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ItemNotFoundError as e:
|
||||||
|
logger.warning("Item not found for drop",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to drop item",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(
|
||||||
|
code="DROP_ITEM_ERROR",
|
||||||
|
message="Failed to drop item",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
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
|
||||||
@@ -1498,166 +1498,68 @@ character.inventory.append(generated_item.to_dict()) # Store full item data
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Task 2.4: Inventory API Endpoints (1 day / 8 hours)
|
#### Task 2.4: Inventory API Endpoints (1 day / 8 hours) ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** REST API for inventory management
|
**Objective:** REST API for inventory management
|
||||||
|
|
||||||
**File:** `/api/app/api/inventory.py`
|
**Files Implemented:**
|
||||||
|
- `/api/app/api/inventory.py` - API blueprint (530 lines)
|
||||||
|
- `/api/tests/test_inventory_api.py` - Integration tests (25 tests)
|
||||||
|
|
||||||
**Endpoints:**
|
**Endpoints Implemented:**
|
||||||
|
|
||||||
```python
|
| Method | Endpoint | Description |
|
||||||
"""
|
|--------|----------|-------------|
|
||||||
Inventory API Blueprint
|
| GET | `/api/v1/characters/<id>/inventory` | Get inventory + 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/remove item |
|
||||||
|
|
||||||
Endpoints:
|
**Exception Handling:**
|
||||||
- GET /api/v1/characters/<id>/inventory - Get inventory
|
- `CharacterNotFound` → 404 Not Found
|
||||||
- POST /api/v1/characters/<id>/inventory/equip - Equip item
|
- `ItemNotFoundError` → 404 Not Found
|
||||||
- POST /api/v1/characters/<id>/inventory/unequip - Unequip item
|
- `InvalidSlotError` → 422 Validation Error
|
||||||
- POST /api/v1/characters/<id>/inventory/use - Use consumable
|
- `CannotEquipError` → 400 Bad Request
|
||||||
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop item
|
- `CannotUseItemError` → 400 Bad Request
|
||||||
"""
|
- `InventoryFullError` → 400 Bad Request
|
||||||
|
|
||||||
from flask import Blueprint, request, g
|
**Response Examples:**
|
||||||
|
|
||||||
from app.services.inventory_service import InventoryService, InventoryError
|
```json
|
||||||
from app.services.character_service import get_character_service
|
// GET /api/v1/characters/{id}/inventory
|
||||||
from app.services.appwrite_service import get_appwrite_service
|
{
|
||||||
from app.utils.response import success_response, error_response, not_found_response
|
"result": {
|
||||||
from app.utils.auth import require_auth
|
"inventory": [{"item_id": "...", "name": "...", ...}],
|
||||||
from app.utils.logging import get_logger
|
"equipped": {
|
||||||
|
"weapon": {...},
|
||||||
|
"helmet": null,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"inventory_count": 5,
|
||||||
|
"max_inventory": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
// POST /api/v1/characters/{id}/inventory/equip
|
||||||
|
{
|
||||||
inventory_bp = Blueprint('inventory', __name__)
|
"result": {
|
||||||
|
"message": "Equipped Flaming Dagger to weapon slot",
|
||||||
|
"equipped": {...},
|
||||||
@inventory_bp.route('/<character_id>/inventory', methods=['GET'])
|
"unequipped_item": null
|
||||||
@require_auth
|
}
|
||||||
def get_inventory(character_id: str):
|
}
|
||||||
"""Get character inventory."""
|
|
||||||
char_service = get_character_service()
|
|
||||||
character = char_service.get_character(character_id, g.user_id)
|
|
||||||
|
|
||||||
inventory_service = InventoryService(get_appwrite_service())
|
|
||||||
items = inventory_service.get_inventory(character)
|
|
||||||
|
|
||||||
return success_response({
|
|
||||||
"inventory": [item.to_dict() for item in items],
|
|
||||||
"equipped": character.equipped
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/<character_id>/inventory/equip', methods=['POST'])
|
|
||||||
@require_auth
|
|
||||||
def equip_item(character_id: str):
|
|
||||||
"""
|
|
||||||
Equip item.
|
|
||||||
|
|
||||||
Request JSON:
|
|
||||||
{
|
|
||||||
"item_id": "iron_sword",
|
|
||||||
"slot": "weapon"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
data = request.get_json()
|
|
||||||
item_id = data.get('item_id')
|
|
||||||
slot = data.get('slot')
|
|
||||||
|
|
||||||
if not item_id or not slot:
|
|
||||||
return error_response("item_id and slot required", 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
char_service = get_character_service()
|
|
||||||
character = char_service.get_character(character_id, g.user_id)
|
|
||||||
|
|
||||||
inventory_service = InventoryService(get_appwrite_service())
|
|
||||||
inventory_service.equip_item(character, item_id, slot)
|
|
||||||
|
|
||||||
# Save character
|
|
||||||
char_service.update_character(character)
|
|
||||||
|
|
||||||
return success_response({
|
|
||||||
"equipped": character.equipped,
|
|
||||||
"message": f"Equipped {item_id} to {slot}"
|
|
||||||
})
|
|
||||||
|
|
||||||
except InventoryError as e:
|
|
||||||
return error_response(str(e), 400)
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/<character_id>/inventory/unequip', methods=['POST'])
|
|
||||||
@require_auth
|
|
||||||
def unequip_item(character_id: str):
|
|
||||||
"""
|
|
||||||
Unequip item.
|
|
||||||
|
|
||||||
Request JSON:
|
|
||||||
{
|
|
||||||
"slot": "weapon"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
data = request.get_json()
|
|
||||||
slot = data.get('slot')
|
|
||||||
|
|
||||||
if not slot:
|
|
||||||
return error_response("slot required", 400)
|
|
||||||
|
|
||||||
char_service = get_character_service()
|
|
||||||
character = char_service.get_character(character_id, g.user_id)
|
|
||||||
|
|
||||||
inventory_service = InventoryService(get_appwrite_service())
|
|
||||||
inventory_service.unequip_item(character, slot)
|
|
||||||
|
|
||||||
# Save character
|
|
||||||
char_service.update_character(character)
|
|
||||||
|
|
||||||
return success_response({
|
|
||||||
"equipped": character.equipped,
|
|
||||||
"message": f"Unequipped item from {slot}"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/<character_id>/inventory/use', methods=['POST'])
|
|
||||||
@require_auth
|
|
||||||
def use_item(character_id: str):
|
|
||||||
"""
|
|
||||||
Use consumable item.
|
|
||||||
|
|
||||||
Request JSON:
|
|
||||||
{
|
|
||||||
"item_id": "health_potion_small"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
data = request.get_json()
|
|
||||||
item_id = data.get('item_id')
|
|
||||||
|
|
||||||
if not item_id:
|
|
||||||
return error_response("item_id required", 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
char_service = get_character_service()
|
|
||||||
character = char_service.get_character(character_id, g.user_id)
|
|
||||||
|
|
||||||
inventory_service = InventoryService(get_appwrite_service())
|
|
||||||
result = inventory_service.use_consumable(character, item_id)
|
|
||||||
|
|
||||||
# Save character
|
|
||||||
char_service.update_character(character)
|
|
||||||
|
|
||||||
return success_response(result)
|
|
||||||
|
|
||||||
except InventoryError as e:
|
|
||||||
return error_response(str(e), 400)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Register blueprint in `/api/app/__init__.py`**
|
**Blueprint registered in `/api/app/__init__.py`**
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Tests:** 25 passing (`/api/tests/test_inventory_api.py`)
|
||||||
- All inventory endpoints functional
|
|
||||||
- Authentication required
|
**Acceptance Criteria:** ✅ MET
|
||||||
- Ownership validation enforced
|
- [x] All inventory endpoints functional
|
||||||
- Errors handled gracefully
|
- [x] Authentication required on all endpoints
|
||||||
|
- [x] Ownership validation enforced
|
||||||
|
- [x] Errors handled gracefully with proper HTTP status codes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user