Add: Unit tests for habit_bot and stock_tracker
- tests/test_habit_bot.py: Habit tracking, food logging, keto guidance - tests/test_stock_tracker.py: Portfolio management, P&L calculation - pytest.ini: Pytest configuration - Updated Jenkinsfile: Emphasized testing stages before build Pipeline stages: 1. Code Quality Gates (lint + security) 2. Unit Tests (pytest with coverage) 3. Integration Tests (Oracle, Telegram, Gitea) 4. Build (only after tests pass) 5. Deploy to Staging
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
255
tests/test_habit_bot.py
Normal file
255
tests/test_habit_bot.py
Normal file
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for Habit Bot
|
||||
Tests: habit tracking, food logging, data persistence
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Test data directory
|
||||
TEST_DATA_DIR = '/tmp/test_habit_bot'
|
||||
os.makedirs(TEST_DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_data():
|
||||
"""Create mock data for testing"""
|
||||
return {
|
||||
'users': {},
|
||||
'habits': {},
|
||||
'habit_logs': {},
|
||||
'food_logs': {},
|
||||
'sessions': {}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_mock_data(mock_data):
|
||||
"""Create app with mock data"""
|
||||
with patch('builtins.open', side_effect=lambda f, *args, **kwargs:
|
||||
(MagicMock() if 'write' in str(f) else
|
||||
(MagicMock() if any(x in str(f) for x in ['users.json', 'habits.json', 'habit_logs.json', 'food_logs.json', 'sessions.json']) else open(f, *args, **kwargs)))):
|
||||
pass
|
||||
|
||||
# Mock load_json and save_json
|
||||
def mock_load_json(f):
|
||||
if 'users' in str(f):
|
||||
return mock_data['users']
|
||||
elif 'habits' in str(f):
|
||||
return mock_data['habits']
|
||||
elif 'habit_logs' in str(f):
|
||||
return mock_data['habit_logs']
|
||||
elif 'food_logs' in str(f):
|
||||
return mock_data['food_logs']
|
||||
elif 'sessions' in str(f):
|
||||
return mock_data['sessions']
|
||||
return {}
|
||||
|
||||
with patch('builtins.open', side_effect=lambda f, mode='r', *args, **kwargs:
|
||||
(MagicMock(__enter__=MagicMock(return_value=StringIO(json.dumps(mock_data.get(f.split('/')[-1], {}))),
|
||||
__exit__=MagicMock(return_value=False)) if any(x in str(f) for x in ['users', 'habits', 'habit_logs', 'food_logs', 'sessions']) else open(f, mode, *args, **kwargs))):
|
||||
with patch('habit_bot.load_json', side_effect=mock_load_json):
|
||||
yield mock_data
|
||||
|
||||
|
||||
class TestHabitBot:
|
||||
"""Test habit tracking functionality"""
|
||||
|
||||
def test_add_habit(self, mock_data):
|
||||
"""Test adding a new habit"""
|
||||
habit_name = "morning workout"
|
||||
|
||||
# Simulate adding habit
|
||||
user_id = "12345"
|
||||
if user_id not in mock_data['habits']:
|
||||
mock_data['habits'][user_id] = {}
|
||||
|
||||
mock_data['habits'][user_id][habit_name] = {
|
||||
'name': habit_name,
|
||||
'streak': 0,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
assert habit_name in mock_data['habits'][user_id]
|
||||
assert mock_data['habits'][user_id][habit_name]['streak'] == 0
|
||||
print(f"✅ Added habit: {habit_name}")
|
||||
|
||||
def test_log_habit_completion(self, mock_data):
|
||||
"""Test logging habit completion"""
|
||||
habit_name = "read books"
|
||||
user_id = "12345"
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Initialize data
|
||||
if user_id not in mock_data['habits']:
|
||||
mock_data['habits'][user_id] = {}
|
||||
mock_data['habits'][user_id][habit_name] = {'streak': 5}
|
||||
|
||||
if user_id not in mock_data['habit_logs']:
|
||||
mock_data['habit_logs'][user_id] = {}
|
||||
if today not in mock_data['habit_logs'][user_id]:
|
||||
mock_data['habit_logs'][user_id][today] = []
|
||||
|
||||
# Log completion
|
||||
mock_data['habit_logs'][user_id][today].append({
|
||||
'habit_name': habit_name,
|
||||
'status': 'completed',
|
||||
'notes': '30 minutes reading',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Update streak
|
||||
mock_data['habits'][user_id][habit_name]['streak'] += 1
|
||||
|
||||
assert len(mock_data['habit_logs'][user_id][today]) == 1
|
||||
assert mock_data['habits'][user_id][habit_name]['streak'] == 6
|
||||
print(f"✅ Logged habit: {habit_name} (streak: 6)")
|
||||
|
||||
def test_habit_streak_calculation(self, mock_data):
|
||||
"""Test streak calculation"""
|
||||
user_id = "12345"
|
||||
habit_name = "exercise"
|
||||
|
||||
# Simulate 7-day streak
|
||||
mock_data['habits'][user_id] = {
|
||||
habit_name: {'streak': 7}
|
||||
}
|
||||
|
||||
assert mock_data['habits'][user_id][habit_name]['streak'] == 7
|
||||
print(f"✅ Streak calculated: 7 days")
|
||||
|
||||
|
||||
class TestFoodLogging:
|
||||
"""Test food/nutrition logging functionality"""
|
||||
|
||||
def test_analyze_simple_food(self, mock_data):
|
||||
"""Test basic food analysis"""
|
||||
from habit_bot import analyze_food_text
|
||||
|
||||
# Test chicken analysis
|
||||
result = analyze_food_text("chicken breast 200g")
|
||||
|
||||
assert 'calories' in result
|
||||
assert 'carbs' in result
|
||||
assert 'protein' in result
|
||||
assert 'fat' in result
|
||||
assert result['protein'] > 0
|
||||
print(f"✅ Food analyzed: {result}")
|
||||
|
||||
def test_analyze_multiple_foods(self, mock_data):
|
||||
"""Test multi-food analysis"""
|
||||
from habit_bot import analyze_food_text
|
||||
|
||||
# Test multiple items
|
||||
result = analyze_food_text("2 eggs and 1 banana")
|
||||
|
||||
assert result['calories'] > 0
|
||||
assert result['protein'] > 0
|
||||
assert 'egg' in result or result['protein'] > 0 # Eggs contribute protein
|
||||
print(f"✅ Multi-food analyzed: {result}")
|
||||
|
||||
def test_food_log_entry(self, mock_data):
|
||||
"""Test food log entry creation"""
|
||||
user_id = "12345"
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Create food log
|
||||
if user_id not in mock_data['food_logs']:
|
||||
mock_data['food_logs'][user_id] = {}
|
||||
if today not in mock_data['food_logs'][user_id]:
|
||||
mock_data['food_logs'][user_id][today] = []
|
||||
|
||||
mock_data['food_logs'][user_id][today].append({
|
||||
'meal_type': 'lunch',
|
||||
'food_name': 'grilled chicken',
|
||||
'time': '12:30',
|
||||
'calories': 300,
|
||||
'carbs': 0,
|
||||
'protein': 50,
|
||||
'fat': 8,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
assert len(mock_data['food_logs'][user_id][today]) == 1
|
||||
assert mock_data['food_logs'][user_id][today][0]['calories'] == 300
|
||||
print("✅ Food log entry created")
|
||||
|
||||
|
||||
class TestKetoGuidance:
|
||||
"""Test keto diet guidance"""
|
||||
|
||||
def test_keto_calorie_targets(self, mock_data):
|
||||
"""Test keto calorie calculation"""
|
||||
# Keto guidelines
|
||||
protein_per_kg = 1.3 # 1.3g per kg body weight
|
||||
body_weight_kg = 70 # Example weight
|
||||
|
||||
protein_target = protein_per_kg * body_weight_kg
|
||||
max_net_carbs = 25 # 25g per day
|
||||
|
||||
assert protein_target == 91 # 1.3 * 70
|
||||
assert max_net_carbs == 25
|
||||
print(f"✅ Keto targets: Protein {protein_target}g, Carbs {max_net_carbs}g")
|
||||
|
||||
def test_calorie_remaining(self, mock_data):
|
||||
"""Test remaining calorie calculation"""
|
||||
daily_target = 2000
|
||||
consumed = 750
|
||||
|
||||
remaining = daily_target - consumed
|
||||
|
||||
assert remaining == 1250
|
||||
print(f"✅ Calories remaining: {remaining}")
|
||||
|
||||
|
||||
class TestDataPersistence:
|
||||
"""Test data save/load functionality"""
|
||||
|
||||
def test_save_and_load_habits(self, mock_data, tmp_path):
|
||||
"""Test habit data persistence"""
|
||||
test_file = tmp_path / "test_habits.json"
|
||||
|
||||
# Save
|
||||
mock_data['habits']['user1'] = {
|
||||
'workout': {'streak': 10},
|
||||
'meditation': {'streak': 5}
|
||||
}
|
||||
|
||||
with open(test_file, 'w') as f:
|
||||
json.dump(mock_data['habits'], f)
|
||||
|
||||
# Load
|
||||
with open(test_file, 'r') as f:
|
||||
loaded = json.load(f)
|
||||
|
||||
assert 'user1' in loaded
|
||||
assert 'workout' in loaded['user1']
|
||||
assert loaded['user1']['workout']['streak'] == 10
|
||||
print("✅ Data persistence verified")
|
||||
|
||||
|
||||
class TestMotivationalQuotes:
|
||||
"""Test motivational quote system"""
|
||||
|
||||
def test_quotes_available(self, mock_data):
|
||||
"""Test that quotes are available"""
|
||||
from habit_bot import MOTIVATIONAL_QUOTES
|
||||
|
||||
assert len(MOTIVATIONAL_QUOTES) > 0
|
||||
assert all(isinstance(q, str) for q in MOTIVATIONAL_QUOTES)
|
||||
assert len(q) > 10 for q in MOTIVATIONAL_QUOTES) # Quotes should have content
|
||||
print(f"✅ {len(MOTIVATIONAL_QUOTES)} motivational quotes available")
|
||||
|
||||
|
||||
# Pytest configuration
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
319
tests/test_stock_tracker.py
Normal file
319
tests/test_stock_tracker.py
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for Stock Tracker
|
||||
Tests: Portfolio management, P&L calculation, price fetching
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
TEST_DATA_DIR = '/tmp/test_stock_tracker'
|
||||
os.makedirs(TEST_DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_prices():
|
||||
"""Create mock price data"""
|
||||
return {
|
||||
'stock_AAPL': {
|
||||
'symbol': 'AAPL',
|
||||
'current_price': 180.0,
|
||||
'change_percent': 2.5,
|
||||
'high_52w': 200.0,
|
||||
'low_52w': 150.0,
|
||||
'volume': 50000000,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
},
|
||||
'stock_MSFT': {
|
||||
'symbol': 'MSFT',
|
||||
'current_price': 380.0,
|
||||
'change_percent': -1.2,
|
||||
'high_52w': 420.0,
|
||||
'low_52w': 310.0,
|
||||
'volume': 25000000,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
},
|
||||
'crypto_BTC': {
|
||||
'symbol': 'BTC',
|
||||
'current_price': 45000.0,
|
||||
'change_percent': 3.8,
|
||||
'high_52w': 69000.0,
|
||||
'low_52w': 35000.0,
|
||||
'volume': 30000000000,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_positions():
|
||||
"""Create mock positions"""
|
||||
return {
|
||||
'stock_AAPL': {
|
||||
'symbol': 'AAPL',
|
||||
'asset_type': 'stock',
|
||||
'quantity': 10,
|
||||
'avg_cost': 150.0,
|
||||
'entry_date': '2025-01-15',
|
||||
'streak': 0,
|
||||
'is_active': True
|
||||
},
|
||||
'stock_MSFT': {
|
||||
'symbol': 'MSFT',
|
||||
'asset_type': 'stock',
|
||||
'quantity': 5,
|
||||
'avg_cost': 350.0,
|
||||
'entry_date': '2025-02-01',
|
||||
'streak': 0,
|
||||
'is_active': True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestPortfolioManagement:
|
||||
"""Test portfolio management functionality"""
|
||||
|
||||
def test_add_position(self, mock_positions):
|
||||
"""Test adding a new position"""
|
||||
from stock_tracker import StockTracker, Position
|
||||
|
||||
# Mock the file operations
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = {}
|
||||
|
||||
# Add a position
|
||||
result = tracker.add_position(
|
||||
symbol='NVDA',
|
||||
asset_type='stock',
|
||||
quantity=10,
|
||||
avg_cost=800.0
|
||||
)
|
||||
|
||||
assert result == True
|
||||
key = 'stock_NVDA'
|
||||
assert key in tracker.positions
|
||||
assert tracker.positions[key].symbol == 'NVDA'
|
||||
assert tracker.positions[key].quantity == 10
|
||||
assert tracker.positions[key].avg_cost == 800.0
|
||||
print(f"✅ Added position: NVDA 10 @ $800")
|
||||
|
||||
def test_remove_position(self, mock_positions):
|
||||
"""Test removing a position"""
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = mock_positions
|
||||
|
||||
# Remove a position
|
||||
result = tracker.remove_position('AAPL', 'stock')
|
||||
|
||||
assert result == True
|
||||
assert 'stock_AAPL' not in tracker.positions
|
||||
print("✅ Removed position: AAPL")
|
||||
|
||||
def test_get_positions(self, mock_positions):
|
||||
"""Test getting all positions"""
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = mock_positions
|
||||
|
||||
positions = tracker.get_positions()
|
||||
|
||||
assert len(positions) == 2
|
||||
print(f"✅ Retrieved {len(positions)} positions")
|
||||
|
||||
|
||||
class TestPnLCalculation:
|
||||
"""Test P&L calculation functionality"""
|
||||
|
||||
def test_calculate_profit(self, mock_positions, mock_prices):
|
||||
"""Test profit calculation for winning position"""
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = mock_positions
|
||||
tracker.prices = mock_prices
|
||||
|
||||
summary = tracker.calculate_portfolio_summary()
|
||||
|
||||
# AAPL: Bought @ $150, Current @ $180 = +20% profit
|
||||
assert summary.total_value > summary.total_cost
|
||||
assert summary.total_pnl_percent > 0
|
||||
print(f"✅ Profit calculated: {summary.total_pnl_percent:.1f}%")
|
||||
|
||||
def test_calculate_loss(self, mock_positions, mock_prices):
|
||||
"""Test loss calculation for losing position"""
|
||||
# Modify MSFT to have a loss
|
||||
mock_positions['stock_MSFT']['avg_cost'] = 400.0 # Bought higher than current
|
||||
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = mock_positions
|
||||
tracker.prices = mock_prices
|
||||
|
||||
summary = tracker.calculate_portfolio_summary()
|
||||
|
||||
# MSFT: Bought @ $400, Current @ $380 = -5% loss
|
||||
msft_pos = next((p for p in summary.positions if p['symbol'] == 'MSFT'), None)
|
||||
assert msft_pos['pnl_percent'] < 0
|
||||
print(f"✅ Loss calculated: MSFT {msft_pos['pnl_percent']:.1f}%")
|
||||
|
||||
def test_pnl_percentage(self, mock_positions):
|
||||
"""Test P&L percentage calculation"""
|
||||
avg_cost = 100.0
|
||||
current_price = 150.0
|
||||
expected_pnl_percent = 50.0
|
||||
|
||||
pnl_percent = ((current_price - avg_cost) / avg_cost) * 100
|
||||
|
||||
assert pnl_percent == expected_pnl_percent
|
||||
print(f"✅ P&L % calculated: {pnl_percent}%")
|
||||
|
||||
|
||||
class TestInvestmentGuidelineChecks:
|
||||
"""Test investment guideline compliance"""
|
||||
|
||||
def test_checklist_score_calculation(self):
|
||||
"""Test 7-item checklist scoring"""
|
||||
checklist = {
|
||||
'story_clear': True,
|
||||
'earnings_uptrend': True,
|
||||
'balance_sheet_healthy': True,
|
||||
'capital_return_plan': True,
|
||||
'governance_clean': True,
|
||||
'market_liquidity': True,
|
||||
'relative_strength': False
|
||||
}
|
||||
|
||||
score = sum(checklist.values())
|
||||
max_score = len(checklist)
|
||||
|
||||
assert score == 6
|
||||
assert f"{score}/{max_score}" == "6/7"
|
||||
print(f"✅ Checklist score: {score}/{max_score}")
|
||||
|
||||
def test_pbr_evaluation(self):
|
||||
"""Test PBR evaluation logic"""
|
||||
# PBR < 1 is generally considered undervalued
|
||||
pbr_values = {
|
||||
'AAPL': 0.85, # Undervalued
|
||||
'MSFT': 1.5, # Fair value
|
||||
'GOOGL': 2.1, # Premium
|
||||
'NVDA': 25.0 # Expensive (but justified by growth)
|
||||
}
|
||||
|
||||
for symbol, pbr in pbr_values.items():
|
||||
if pbr < 1:
|
||||
status = "undervalued"
|
||||
elif pbr < 3:
|
||||
status = "fair value"
|
||||
else:
|
||||
status = "premium"
|
||||
print(f"✅ {symbol} PBR: {pbr}x ({status})")
|
||||
|
||||
def test_stop_loss_calculation(self):
|
||||
"""Test -10% stop loss calculation"""
|
||||
entry_price = 100000 # KRW
|
||||
|
||||
# Hard stop loss
|
||||
stop_loss_price = entry_price * 0.9 # -10%
|
||||
assert stop_loss_price == 90000
|
||||
|
||||
# Trailing stop (from high)
|
||||
high_price = 120000
|
||||
trailing_stop = high_price * 0.9 # -10% from high
|
||||
assert trailing_stop == 108000
|
||||
|
||||
print(f"✅ Stop loss: {stop_loss_price} (entry: {entry_price})")
|
||||
print(f"✅ Trailing stop: {trailing_stop} (high: {high_price})")
|
||||
|
||||
|
||||
class TestReportGeneration:
|
||||
"""Test report generation functionality"""
|
||||
|
||||
def test_daily_report_structure(self, mock_positions, mock_prices):
|
||||
"""Test daily report has required sections"""
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = mock_positions
|
||||
tracker.prices = mock_prices
|
||||
|
||||
report = tracker.generate_daily_report()
|
||||
|
||||
# Check report contains key sections
|
||||
assert '일일 포트폴리오 리포트' in report or 'Daily' in report
|
||||
assert '총 가치' in report or 'Total Value' in report
|
||||
assert '손익' in report or 'P&L' in report
|
||||
print("✅ Daily report structure verified")
|
||||
|
||||
def test_weekly_report_structure(self, mock_positions, mock_prices):
|
||||
"""Test weekly report has required sections"""
|
||||
with patch('stock_tracker.load_json', return_value={}):
|
||||
with patch('stock_tracker.save_json'):
|
||||
tracker = StockTracker.__new__(StockTracker)
|
||||
tracker.positions = mock_positions
|
||||
tracker.prices = mock_prices
|
||||
|
||||
report = tracker.generate_weekly_report()
|
||||
|
||||
# Check report contains key sections
|
||||
assert '주간 포트폴리오 리포트' in report or 'Weekly' in report
|
||||
assert '목표' in report or 'Goal' in report
|
||||
assert '체크리스트' in report or 'Checklist' in report
|
||||
print("✅ Weekly report structure verified")
|
||||
|
||||
|
||||
class TestDataTypes:
|
||||
"""Test data type validation"""
|
||||
|
||||
def test_position_validation(self):
|
||||
"""Test Position dataclass"""
|
||||
from stock_tracker import Position
|
||||
|
||||
pos = Position(
|
||||
symbol='TEST',
|
||||
asset_type='stock',
|
||||
quantity=100,
|
||||
avg_cost=50.0,
|
||||
entry_date='2025-01-01'
|
||||
)
|
||||
|
||||
assert pos.symbol == 'TEST'
|
||||
assert pos.quantity == 100
|
||||
assert pos.avg_cost == 50.0
|
||||
assert pos.is_active == True
|
||||
print("✅ Position validation passed")
|
||||
|
||||
def test_price_data_validation(self):
|
||||
"""Test PriceData dataclass"""
|
||||
from stock_tracker import PriceData
|
||||
|
||||
price = PriceData(
|
||||
symbol='TEST',
|
||||
current_price=100.0,
|
||||
change_percent=2.5,
|
||||
high_52w=120.0,
|
||||
low_52w=80.0,
|
||||
volume=1000000.0
|
||||
)
|
||||
|
||||
assert price.symbol == 'TEST'
|
||||
assert price.current_price == 100.0
|
||||
assert price.change_percent == 2.5
|
||||
print("✅ PriceData validation passed")
|
||||
|
||||
|
||||
# Pytest configuration
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Reference in New Issue
Block a user