|
1 | 1 | import pytest |
2 | 2 | from datetime import datetime, timezone |
3 | | -from unittest.mock import Mock, ANY |
| 3 | +from unittest.mock import Mock, ANY, patch |
4 | 4 | from usecase.stock import StockUsecase |
5 | 5 | from domain.stock import CreateStock, ActionType, Stock |
6 | 6 | from domain.portfolio import Portfolio, Holding |
@@ -410,3 +410,182 @@ def test_list_handles_repository_error(self, stock_usecase): |
410 | 410 | with pytest.raises(Exception, match="Repository error"): |
411 | 411 | usecase.list(user_id) |
412 | 412 | mock_repo.list.assert_called_once_with(user_id) |
| 413 | + |
| 414 | + def test_calculate_total_roi_no_portfolio(self, stock_usecase): |
| 415 | + # Arrange |
| 416 | + usecase, _, portfolio_repo = stock_usecase |
| 417 | + user_id = 1 |
| 418 | + portfolio_repo.get.return_value = None |
| 419 | + |
| 420 | + # Act |
| 421 | + result = usecase.calculate_total_roi(user_id) |
| 422 | + |
| 423 | + # Assert |
| 424 | + portfolio_repo.get.assert_called_once_with(user_id=user_id) |
| 425 | + assert result == 0.0 |
| 426 | + |
| 427 | + def test_calculate_total_roi_no_total_money_in(self, stock_usecase): |
| 428 | + # Arrange |
| 429 | + usecase, _, portfolio_repo = stock_usecase |
| 430 | + user_id = 1 |
| 431 | + portfolio = Portfolio( |
| 432 | + user_id=user_id, |
| 433 | + cash_balance=0.0, |
| 434 | + total_money_in=0.0, |
| 435 | + holdings=[], |
| 436 | + created_at=ANY, |
| 437 | + updated_at=ANY, |
| 438 | + ) |
| 439 | + portfolio_repo.get.return_value = portfolio |
| 440 | + |
| 441 | + # Act |
| 442 | + result = usecase.calculate_total_roi(user_id) |
| 443 | + |
| 444 | + # Assert |
| 445 | + portfolio_repo.get.assert_called_once_with(user_id=user_id) |
| 446 | + assert result == 0.0 |
| 447 | + |
| 448 | + def test_calculate_total_roi_no_holdings(self, stock_usecase): |
| 449 | + # Arrange |
| 450 | + usecase, _, portfolio_repo = stock_usecase |
| 451 | + user_id = 1 |
| 452 | + portfolio = Portfolio( |
| 453 | + user_id=user_id, |
| 454 | + cash_balance=1000.0, |
| 455 | + total_money_in=1000.0, |
| 456 | + holdings=[], |
| 457 | + created_at=ANY, |
| 458 | + updated_at=ANY, |
| 459 | + ) |
| 460 | + portfolio_repo.get.return_value = portfolio |
| 461 | + |
| 462 | + # Act |
| 463 | + result = usecase.calculate_total_roi(user_id) |
| 464 | + |
| 465 | + # Assert |
| 466 | + portfolio_repo.get.assert_called_once_with(user_id=user_id) |
| 467 | + assert result == 0.0 # ROI = ((1000 - 1000) / 1000) * 100 = 0.0 |
| 468 | + |
| 469 | + @patch.object(StockUsecase, "_get_stock_price") |
| 470 | + def test_calculate_total_roi_with_holdings(self, mock_get_stock_price, stock_usecase): |
| 471 | + # Arrange |
| 472 | + usecase, _, portfolio_repo = stock_usecase |
| 473 | + user_id = 1 |
| 474 | + portfolio = Portfolio( |
| 475 | + user_id=user_id, |
| 476 | + cash_balance=1000.0, |
| 477 | + total_money_in=5000.0, |
| 478 | + holdings=[ |
| 479 | + Holding(symbol="AAPL", shares=10, total_cost=1500.0), |
| 480 | + Holding(symbol="GOOGL", shares=5, total_cost=2000.0), |
| 481 | + Holding(symbol="TSLA", shares=0, total_cost=0.0), # Zero shares, should be ignored |
| 482 | + ], |
| 483 | + created_at=ANY, |
| 484 | + updated_at=ANY, |
| 485 | + ) |
| 486 | + portfolio_repo.get.return_value = portfolio |
| 487 | + mock_get_stock_price.return_value = { |
| 488 | + "AAPL": 200.0, # 10 shares * 200 = 2000 |
| 489 | + "GOOGL": 3000.0, # 5 shares * 3000 = 15000 |
| 490 | + } |
| 491 | + |
| 492 | + # Act |
| 493 | + result = usecase.calculate_total_roi(user_id) |
| 494 | + |
| 495 | + # Assert |
| 496 | + portfolio_repo.get.assert_called_once_with(user_id=user_id) |
| 497 | + mock_get_stock_price.assert_called_once_with(symbols=["AAPL", "GOOGL"]) |
| 498 | + # Total value = 2000 (AAPL) + 15000 (GOOGL) + 1000 (cash) = 18000 |
| 499 | + # ROI = ((18000 - 5000) / 5000) * 100 = 260.0 |
| 500 | + assert result == 260.0 |
| 501 | + |
| 502 | + @patch.object(StockUsecase, "_get_stock_price") |
| 503 | + def test_calculate_total_roi_with_missing_prices(self, mock_get_stock_price, stock_usecase): |
| 504 | + # Arrange |
| 505 | + usecase, _, portfolio_repo = stock_usecase |
| 506 | + user_id = 1 |
| 507 | + portfolio = Portfolio( |
| 508 | + user_id=user_id, |
| 509 | + cash_balance=1000.0, |
| 510 | + total_money_in=5000.0, |
| 511 | + holdings=[ |
| 512 | + Holding(symbol="AAPL", shares=10, total_cost=1500.0), |
| 513 | + Holding(symbol="INVALID", shares=5, total_cost=2000.0), |
| 514 | + ], |
| 515 | + created_at=ANY, |
| 516 | + updated_at=ANY, |
| 517 | + ) |
| 518 | + portfolio_repo.get.return_value = portfolio |
| 519 | + mock_get_stock_price.return_value = { |
| 520 | + "AAPL": 200.0, # 10 shares * 200 = 2000 |
| 521 | + "INVALID": 0.0, # No price available |
| 522 | + } |
| 523 | + |
| 524 | + # Act |
| 525 | + result = usecase.calculate_total_roi(user_id) |
| 526 | + |
| 527 | + # Assert |
| 528 | + portfolio_repo.get.assert_called_once_with(user_id=user_id) |
| 529 | + mock_get_stock_price.assert_called_once_with(symbols=["AAPL", "INVALID"]) |
| 530 | + # Total value = 2000 (AAPL) + 0 (INVALID) + 1000 (cash) = 3000 |
| 531 | + # ROI = ((3000 - 5000) / 5000) * 100 = -40.0 |
| 532 | + assert result == -40.0 |
| 533 | + |
| 534 | + def test_calculate_total_roi_handles_repository_error(self, stock_usecase): |
| 535 | + # Arrange |
| 536 | + usecase, _, portfolio_repo = stock_usecase |
| 537 | + user_id = 1 |
| 538 | + portfolio_repo.get.side_effect = Exception("Portfolio repository error") |
| 539 | + |
| 540 | + # Act/Assert |
| 541 | + with pytest.raises(Exception, match="Portfolio repository error"): |
| 542 | + usecase.calculate_total_roi(user_id) |
| 543 | + portfolio_repo.get.assert_called_once_with(user_id=user_id) |
| 544 | + |
| 545 | + @patch("usecase.stock.yf.Tickers") |
| 546 | + def test_get_stock_price_success(self, mock_yf_tickers, stock_usecase): |
| 547 | + # Arrange |
| 548 | + usecase, _, _ = stock_usecase |
| 549 | + symbols = ["AAPL", "GOOGL"] |
| 550 | + mock_ticker_aapl = Mock() |
| 551 | + mock_ticker_aapl.info = {"currentPrice": 150.0} |
| 552 | + mock_ticker_googl = Mock() |
| 553 | + mock_ticker_googl.info = {"currentPrice": 2800.0} |
| 554 | + mock_yf_tickers.return_value.tickers = { |
| 555 | + "AAPL": mock_ticker_aapl, |
| 556 | + "GOOGL": mock_ticker_googl, |
| 557 | + } |
| 558 | + |
| 559 | + # Act |
| 560 | + result = usecase._get_stock_price(symbols) |
| 561 | + |
| 562 | + # Assert |
| 563 | + mock_yf_tickers.assert_called_once_with(symbols) |
| 564 | + assert result == {"AAPL": 150.0, "GOOGL": 2800.0} |
| 565 | + |
| 566 | + @patch("usecase.stock.yf.Tickers") |
| 567 | + def test_get_stock_price_empty_symbols(self, mock_yf_tickers, stock_usecase): |
| 568 | + # Arrange |
| 569 | + usecase, _, _ = stock_usecase |
| 570 | + symbols = [] |
| 571 | + |
| 572 | + # Act |
| 573 | + result = usecase._get_stock_price(symbols) |
| 574 | + |
| 575 | + # Assert |
| 576 | + mock_yf_tickers.assert_not_called() |
| 577 | + assert result == {} |
| 578 | + |
| 579 | + @patch("usecase.stock.yf.Tickers") |
| 580 | + def test_get_stock_price_handles_api_error(self, mock_yf_tickers, stock_usecase): |
| 581 | + # Arrange |
| 582 | + usecase, _, _ = stock_usecase |
| 583 | + symbols = ["AAPL", "GOOGL"] |
| 584 | + mock_yf_tickers.side_effect = Exception("API error") |
| 585 | + |
| 586 | + # Act |
| 587 | + result = usecase._get_stock_price(symbols) |
| 588 | + |
| 589 | + # Assert |
| 590 | + mock_yf_tickers.assert_called_once_with(symbols) |
| 591 | + assert result == {"AAPL": 0.0, "GOOGL": 0.0} |
0 commit comments