import unittest from flask import Flask from src.app import app import json from unittest.mock import MagicMock, patch import pandas as pd class FlaskAppTests(unittest.TestCase): def setUp(self): """ Set up the test client and any other test configuration. """ self.app = app.test_client() self.app.testing = True def test_index(self): """ Test the index route. """ response = self.app.get('/') self.assertEqual(response.status_code, 200) self.assertIn(b'Welcome', response.data) # Adjust this based on your actual landing page content def test_login_redirect_on_invalid(self): """ Test that login redirects on invalid credentials. """ # Invalid credentials should redirect to login page invalid_data = {'user_name': 'wrong_user', 'password': 'wrong_password'} response = self.app.post('/login', data=invalid_data) self.assertEqual(response.status_code, 302) # Redirects on failure # Follow redirect to check error flash message response = self.app.post('/login', data=invalid_data, follow_redirects=True) # The page should contain login form or error message self.assertIn(b'login', response.data.lower()) def test_login_with_valid_credentials(self): """ Test the login route with credentials that may or may not exist. """ # Valid credentials (test user may not exist) valid_data = {'user_name': 'test_user', 'password': 'test_password'} response = self.app.post('/login', data=valid_data) # Should redirect regardless (to index if success, to login if failure) self.assertEqual(response.status_code, 302) def test_signup(self): """ Test the signup route. """ import random username = f'test_user_{random.randint(10000, 99999)}' data = {'email': f'{username}@example.com', 'user_name': username, 'password': 'new_password'} response = self.app.post('/signup_submit', data=data) self.assertEqual(response.status_code, 302) # Redirects on success def test_signout(self): """ Test the signout route. """ response = self.app.get('/signout') self.assertEqual(response.status_code, 302) # Redirects on signout def test_indicator_init_requires_auth(self): """ Test that indicator_init requires authentication. """ data = {"user_name": "test_user"} response = self.app.post('/api/indicator_init', data=json.dumps(data), content_type='application/json') # Should return 401 without proper session self.assertIn(response.status_code, [200, 401]) # Either authenticated or not def test_login_page_loads(self): """ Test that login page loads. """ response = self.app.get('/login') self.assertEqual(response.status_code, 200) def test_signup_page_loads(self): """ Test that signup page loads. """ response = self.app.get('/signup') self.assertEqual(response.status_code, 200) @patch('src.app.is_margin_supported', return_value=True) @patch('src.app._get_current_user_id', return_value=1) def test_margin_capabilities_live_uses_margin_gate_signature(self, _mock_user_id, _mock_margin_supported): """Live margin capabilities should use the current can_trade_margin signature.""" with patch('src.app.brighter_trades') as mock_bt: mock_bt.config.is_margin_risk_ack_required.return_value = False mock_bt.config.can_trade_margin.return_value = (True, 'allowed') mock_bt.users.get_username_by_id.return_value = 'rob' exchange = MagicMock() exchange.get_margin_info.return_value = { 'margin_enabled': True, 'margin_modes': ['isolated'] } exchange.fetch_max_leverage.return_value = { 'max_leverage': 5, 'min_leverage': 1 } exchange.fetch_borrow_rate.return_value = { 'hourly_rate': 0.0002 } mock_bt.exchanges.get_exchange.return_value = exchange response = self.app.post( '/api/margin/capabilities', json={ 'broker_key': 'kucoin_margin_isolated_production', 'symbol': 'BTC/USDT' } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['available']) mock_bt.config.can_trade_margin.assert_called_with(1, True, None) @patch('src.app._get_current_user_id', return_value=1) def test_margin_capabilities_paper_respects_risk_ack_gate(self, _mock_user_id): """Paper margin capabilities should be gated until risk acknowledgment is recorded.""" with patch('src.app.brighter_trades') as mock_bt: mock_bt.config.is_margin_risk_ack_required.return_value = True mock_bt.data.db.has_margin_risk_ack.return_value = False mock_bt.config.can_trade_margin.return_value = (False, 'risk_not_acknowledged') response = self.app.post( '/api/margin/capabilities', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT' } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertFalse(data['available']) self.assertTrue(data['gated']) self.assertEqual(data['reason'], 'Margin trading not available: risk_not_acknowledged') self.assertTrue(data['risk_ack_required']) self.assertFalse(data['risk_acknowledged']) mock_bt.config.can_trade_margin.assert_called_with(1, False, False) @patch('src.app._get_current_user_id', return_value=1) def test_margin_history_returns_normalized_rows(self, _mock_user_id): """Margin history route should normalize DB rows for the frontend history panel.""" with patch('src.app.brighter_trades') as mock_bt: mock_bt.data.db.get_margin_position_history.return_value = pd.DataFrame([ { 'id': 7, 'broker_key': 'kucoin_margin_isolated_production', 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.01, 'entry_price': 50000.0, 'close_price': 51000.0, 'pnl_quote': 9.5, 'collateral': 100.0, 'leverage': 3.0, 'close_reason': 'manual', 'opened_at': 1700000000, 'closed_at': 1700003600, } ]) response = self.app.get('/api/margin/history?broker_key=kucoin_margin_isolated_production&limit=5') self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) self.assertEqual(len(data['history']), 1) row = data['history'][0] self.assertEqual(row['unique_id'], 'mph_7') self.assertEqual(row['symbol'], 'BTC/USDT') self.assertEqual(row['side'], 'LONG') self.assertEqual(row['broker_key'], 'kucoin_margin_isolated_production') self.assertEqual(row['pnl_quote'], 9.5) self.assertEqual(row['status'], 'closed') @patch('src.app._get_current_user_id', return_value=1) def test_margin_history_paper_uses_broker_history(self, _mock_user_id): """Paper margin history should come from the paper broker's persisted position history.""" with patch('src.app.brighter_trades') as mock_bt: mock_broker = MagicMock() mock_broker.get_position_history.return_value = [ { 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.01, 'entry_price': 50000.0, 'close_price': 51000.0, 'realized_pnl': 9.5, 'collateral': 100.0, 'leverage': 3.0, 'close_reason': 'manual', 'opened_at': '2023-11-14T22:13:20+00:00', 'closed_at': '2023-11-14T23:13:20+00:00', } ] mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.get('/api/margin/history?broker_key=paper_margin_isolated&limit=5') self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) self.assertEqual(len(data['history']), 1) row = data['history'][0] self.assertEqual(row['symbol'], 'BTC/USDT') self.assertEqual(row['side'], 'LONG') self.assertEqual(row['broker_key'], 'paper_margin_isolated') self.assertTrue(row['is_paper']) self.assertEqual(row['product'], 'margin') self.assertEqual(row['pnl_quote'], 9.5) self.assertEqual(row['opened_at'], 1700000000) self.assertEqual(row['closed_at'], 1700003600) mock_bt.data.db.get_margin_position_history.assert_not_called() @patch('src.app._get_current_user_id', return_value=1) def test_list_margin_positions_paper_normalizes_broker_metadata(self, _mock_user_id): """Paper margin position list should include broker metadata expected by the frontend.""" with patch('src.app.brighter_trades') as mock_bt: mock_position = MagicMock() mock_position.to_dict.return_value = { 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.1, 'collateral': 1000.0 } mock_broker = MagicMock() mock_broker.get_all_positions.return_value = [mock_position] mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/list', json={'broker_key': 'paper_margin_isolated'} ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) self.assertEqual(len(data['positions']), 1) position = data['positions'][0] self.assertEqual(position['broker_key'], 'paper_margin_isolated') self.assertEqual(position['broker_kind'], 'paper') self.assertEqual(position['product'], 'margin') @patch('src.app._get_current_user_id', return_value=1) def test_preview_increase_paper_margin(self, _mock_user_id): """Preview increase endpoint should return valid preview for paper margin.""" with patch('src.app.brighter_trades') as mock_bt: mock_broker = MagicMock() mock_broker.preview_increase.return_value = { 'preview_type': 'increase', 'valid': True, 'current': { 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.1, 'entry_price': 60000, 'collateral': 1000, }, 'projected': { 'total_size': 0.15, 'average_entry': 61000, 'total_collateral': 1500, 'effective_leverage': 6.0, 'liquidation_price': 53000, 'margin_ratio': 85, }, 'warnings': [], 'errors': [] } mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/preview-increase', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'mode': 'collateral_first', 'additional_collateral': 500, 'execution_leverage': 3 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data['preview_type'], 'increase') self.assertTrue(data['valid']) self.assertIsNotNone(data['projected']) self.assertEqual(data['projected']['total_size'], 0.15) @patch('src.app._get_current_user_id', return_value=1) def test_preview_reduce_paper_margin(self, _mock_user_id): """Preview reduce endpoint should return valid preview for paper margin.""" with patch('src.app.brighter_trades') as mock_bt: mock_broker = MagicMock() mock_broker.preview_reduce.return_value = { 'preview_type': 'reduce', 'valid': True, 'current': {'size': 0.1}, 'projected': { 'is_full_close': False, 'remaining_size': 0.05, 'realized_pnl': 100, }, 'warnings': [], 'errors': [] } mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/preview-reduce', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'reduce_size': 0.05 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data['preview_type'], 'reduce') self.assertTrue(data['valid']) @patch('src.app._get_current_user_id', return_value=1) def test_preview_add_margin_paper(self, _mock_user_id): """Preview add margin endpoint should return valid preview.""" with patch('src.app.brighter_trades') as mock_bt: mock_broker = MagicMock() mock_broker.preview_add_margin.return_value = { 'preview_type': 'add_margin', 'valid': True, 'current': {'collateral': 1000}, 'projected': { 'total_collateral': 1500, 'effective_leverage': 4.0, 'margin_ratio': 95, }, 'warnings': [], 'errors': [] } mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/preview-add-margin', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'amount': 500 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data['preview_type'], 'add_margin') self.assertTrue(data['valid']) @patch('src.app._get_current_user_id', return_value=1) def test_preview_remove_margin_paper(self, _mock_user_id): """Preview remove margin endpoint should return valid preview.""" with patch('src.app.brighter_trades') as mock_bt: mock_broker = MagicMock() mock_broker.preview_remove_margin.return_value = { 'preview_type': 'remove_margin', 'valid': True, 'current': {'collateral': 1500}, 'projected': { 'total_collateral': 1000, 'effective_leverage': 6.0, 'max_withdrawable': 600, }, 'warnings': ['Effective leverage will exceed 5x (6.0x)'], 'errors': [] } mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/preview-remove-margin', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'amount': 500 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data['preview_type'], 'remove_margin') self.assertTrue(data['valid']) self.assertEqual(len(data['warnings']), 1) @patch('src.app._get_current_user_id', return_value=1) def test_increase_margin_position_paper(self, _mock_user_id): """Increase position endpoint should execute for paper margin.""" with patch('src.app.brighter_trades') as mock_bt: mock_position = MagicMock() mock_position.to_dict.return_value = { 'symbol': 'BTC/USDT', 'size': 0.15, 'collateral': 1500, } mock_broker = MagicMock() mock_broker.increase_position.return_value = mock_position mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/increase', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'mode': 'collateral_first', 'additional_collateral': 500, 'execution_leverage': 3 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) self.assertIn('position', data) @patch('src.app._get_current_user_id', return_value=1) def test_increase_margin_position_live_executes_and_syncs_position(self, _mock_user_id): """Live increase route should call the live broker and sync the updated position.""" with patch('src.app.brighter_trades') as mock_bt: live_position = { 'symbol': 'BTC/USDT', 'size': 0.02, 'collateral': 150.0, } mock_live_broker = MagicMock() mock_live_broker.increase_position.return_value = { 'success': True, 'position': live_position } mock_bt.manual_broker_manager._get_or_create_live_margin_broker.return_value = mock_live_broker mock_bt.data.db.has_margin_risk_ack.return_value = True mock_bt.config.can_trade_margin.return_value = (True, 'allowed') response = self.app.post( '/api/margin/positions/increase', json={ 'broker_key': 'kucoin_margin_isolated_production', 'symbol': 'BTC/USDT', 'mode': 'collateral_first', 'additional_collateral': 50, 'execution_leverage': 3 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) mock_bt.config.can_trade_margin.assert_called_with(1, True, True) mock_live_broker.increase_position.assert_called_once_with( symbol='BTC/USDT', additional_collateral=50.0, execution_leverage=3.0 ) mock_bt.trades.sync_margin_position.assert_called_once_with( user_id=1, symbol='BTC/USDT', broker_key='kucoin_margin_isolated_production', position=live_position ) @patch('src.app._get_current_user_id', return_value=1) @patch('src.app._record_margin_position_edit') def test_reduce_margin_position_paper_records_preview_metadata(self, mock_record_edit, _mock_user_id): """Paper reduce route should persist preview-derived realized P/L and interest metadata.""" with patch('src.app.brighter_trades') as mock_bt: existing_pos = MagicMock() existing_pos.to_dict.return_value = { 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.1, 'entry_price': 60000.0, 'collateral': 1000.0, } reduced_pos = MagicMock() reduced_pos.to_dict.return_value = { 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.05, 'entry_price': 60000.0, 'collateral': 500.0, } mock_broker = MagicMock() mock_broker.get_position.return_value = existing_pos mock_broker.get_current_price.return_value = 62000.0 mock_broker.preview_reduce.return_value = { 'valid': True, 'projected': { 'realized_pnl': 95.0, 'interest_paid': 5.0, } } mock_broker.reduce_position.return_value = reduced_pos mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/reduce', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'size': 0.05 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) mock_broker.reduce_position.assert_called_with('BTC/USDT', 0.05, current_price=62000.0) mock_record_edit.assert_called_once() kwargs = mock_record_edit.call_args.kwargs self.assertEqual(kwargs['action_type'], 'reduce') self.assertEqual(kwargs['execution_price'], 62000.0) self.assertEqual(kwargs['realized_pnl'], 95.0) self.assertEqual(kwargs['interest_paid'], 5.0) @patch('src.app._get_current_user_id', return_value=1) @patch('src.app._record_margin_position_edit') def test_close_margin_position_paper_records_preview_metadata(self, mock_record_edit, _mock_user_id): """Paper close route should persist preview-derived realized P/L and interest metadata.""" with patch('src.app.brighter_trades') as mock_bt: existing_pos = MagicMock() existing_pos.size = 0.1 existing_pos.to_dict.return_value = { 'symbol': 'BTC/USDT', 'side': 'long', 'size': 0.1, 'entry_price': 60000.0, 'collateral': 1000.0, } close_result = MagicMock() close_result.success = True close_result.close_price = 62000.0 close_result.realized_pnl = 190.0 close_result.message = None mock_broker = MagicMock() mock_broker.get_position.return_value = existing_pos mock_broker.get_current_price.return_value = 62000.0 mock_broker.preview_reduce.return_value = { 'valid': True, 'projected': { 'realized_pnl': 190.0, 'interest_paid': 10.0, } } mock_broker.close_position.return_value = close_result mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/close', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) mock_broker.close_position.assert_called_with('BTC/USDT', current_price=62000.0) mock_record_edit.assert_called_once() kwargs = mock_record_edit.call_args.kwargs self.assertEqual(kwargs['action_type'], 'close') self.assertEqual(kwargs['execution_price'], 62000.0) self.assertEqual(kwargs['realized_pnl'], 190.0) self.assertEqual(kwargs['interest_paid'], 10.0) @patch('src.app._get_current_user_id', return_value=1) def test_remove_margin_from_position_paper(self, _mock_user_id): """Remove margin endpoint should execute for paper margin.""" with patch('src.app.brighter_trades') as mock_bt: mock_position = MagicMock() mock_position.to_dict.return_value = { 'symbol': 'BTC/USDT', 'collateral': 1000, } mock_broker = MagicMock() mock_broker.remove_margin.return_value = mock_position mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker response = self.app.post( '/api/margin/positions/remove-margin', json={ 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'amount': 500 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) self.assertEqual(data['withdrawn_amount'], 500) @patch('src.app._get_current_user_id', return_value=1) def test_remove_margin_from_position_live_executes_and_syncs_position(self, _mock_user_id): """Live remove-margin route should call the live broker and sync the updated position.""" with patch('src.app.brighter_trades') as mock_bt: live_position = { 'symbol': 'BTC/USDT', 'collateral': 120.0, } mock_live_broker = MagicMock() mock_live_broker.remove_margin.return_value = { 'success': True, 'withdrawn_amount': 25.0, 'position': live_position } mock_bt.manual_broker_manager._get_or_create_live_margin_broker.return_value = mock_live_broker mock_bt.data.db.has_margin_risk_ack.return_value = True mock_bt.config.can_trade_margin.return_value = (True, 'allowed') response = self.app.post( '/api/margin/positions/remove-margin', json={ 'broker_key': 'kucoin_margin_isolated_production', 'symbol': 'BTC/USDT', 'amount': 25 } ) self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) mock_bt.config.can_trade_margin.assert_called_with(1, True, True) mock_live_broker.remove_margin.assert_called_once_with('BTC/USDT', 25.0) mock_bt.trades.sync_margin_position.assert_called_once_with( user_id=1, symbol='BTC/USDT', broker_key='kucoin_margin_isolated_production', position=live_position ) @patch('src.app._get_current_user_id', return_value=None) def test_preview_endpoints_require_auth(self, _mock_user_id): """Preview endpoints should require authentication.""" endpoints = [ '/api/margin/positions/preview-increase', '/api/margin/positions/preview-reduce', '/api/margin/positions/preview-add-margin', '/api/margin/positions/preview-remove-margin', '/api/margin/positions/increase', '/api/margin/positions/remove-margin', ] for endpoint in endpoints: response = self.app.post( endpoint, json={'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT'} ) self.assertEqual(response.status_code, 401, f"{endpoint} should require auth") @patch('src.app._get_current_user_id', return_value=1) def test_margin_edits_endpoint_returns_edit_activity(self, _mock_user_id): """Margin edits endpoint should return edit activity records.""" with patch('src.app.brighter_trades') as mock_bt: mock_bt.data.db.get_margin_position_edits.return_value = pd.DataFrame([ { 'id': 1, 'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT', 'action_type': 'increase', 'side': 'long', 'timestamp': 1700000000, 'size_delta': 0.05, 'collateral_delta': 500.0, 'size_before': 0.1, 'size_after': 0.15, 'entry_price_before': 60000.0, 'entry_price_after': 61333.0, 'collateral_before': 1000.0, 'collateral_after': 1500.0, 'margin_ratio_before': 85.0, 'margin_ratio_after': 82.0, 'execution_price': None, 'realized_pnl': None, 'interest_paid': None, } ]) response = self.app.get('/api/margin/edits?broker_key=paper_margin_isolated&limit=10') self.assertEqual(response.status_code, 200) data = response.get_json() self.assertTrue(data['success']) self.assertEqual(len(data['edits']), 1) edit = data['edits'][0] self.assertEqual(edit['unique_id'], 'mpe_1') self.assertEqual(edit['action_type'], 'increase') self.assertEqual(edit['symbol'], 'BTC/USDT') self.assertEqual(edit['side'], 'LONG') self.assertEqual(edit['size_delta'], 0.05) self.assertEqual(edit['collateral_delta'], 500.0) self.assertIn('interest_paid', edit) if __name__ == '__main__': unittest.main()