Skip to content

Commit d4e2c96

Browse files
eyo-chenEyo Chen
andauthored
Feat: add get portfolio info handler (#20)
* feat: correct logic * feat: add proto * feat: add handler * test: add unit testing * fix: correct testing --------- Co-authored-by: Eyo Chen <[email protected]>
1 parent ff05ee7 commit d4e2c96

File tree

9 files changed

+157
-10
lines changed

9 files changed

+157
-10
lines changed

src/adapters/stock.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ def __init__(self, mongo_client: MongoClient, database_name: str = "stock_db"):
1313
self.collection = self.db["stocks"]
1414

1515
def create(self, stock: CreateStock) -> str:
16-
result = self.collection.insert_one(stock.as_dict())
16+
stock_dict = stock.as_dict()
17+
stock_dict["updated_at"] = stock_dict["created_at"]
18+
result = self.collection.insert_one(stock_dict)
1719
return str(result.inserted_id)
1820

1921
def list(self, user_id: int) -> List[Stock]:

src/domain/portfolio.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,6 @@ class Portfolio:
5050
updated_at: datetime
5151

5252
def __post_init__(self):
53-
if self.cash_balance < 0:
54-
raise ValueError("cash_balance cannot be negative")
5553
if self.total_money_in < 0:
5654
raise ValueError("total_money_in cannot be negative")
5755

src/handler/stock.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
from typing import List as ListType
12
import logging
23
from datetime import datetime, timezone
34
import grpc
45
import proto.stock_pb2 as stock_pb2
56
import proto.stock_pb2_grpc as stock_pb2_grpc
67
from usecase.base import AbstractStockUsecase
7-
from domain.stock import CreateStock
8+
from domain.stock import CreateStock, Stock
89
from domain.enum import ActionType, ACTION_MAP, StockType, STOCK_MAP
910

1011

@@ -58,6 +59,27 @@ def List(self, request, context):
5859
context.set_details("Internal server error")
5960
raise grpc.RpcError("Internal server error")
6061

62+
def GetPortfolioInfo(self, request, context):
63+
try:
64+
user_id = request.user_id
65+
info = self.stock_usecase.get_portfolio_info(user_id=user_id)
66+
67+
return stock_pb2.GetPortfolioInfoResp(
68+
user_id=user_id,
69+
total_portfolio_value=info.total_portfolio_value,
70+
total_gain=info.total_gain,
71+
roi=info.roi,
72+
)
73+
except Exception as e:
74+
logging.error(
75+
"Failed to get portfolio info for user_id=%s: %s",
76+
request.user_id,
77+
str(e),
78+
)
79+
context.set_code(grpc.StatusCode.INTERNAL)
80+
context.set_details("Internal server error")
81+
raise grpc.RpcError("Internal server error")
82+
6183
def _map_action_type(self, action: int) -> ActionType:
6284
if action not in ACTION_MAP:
6385
raise ValueError(f"Invalid action type: {action}. Must be 1 (BUY), 2 (SELL), or 3 (TRANSFER).")
@@ -68,7 +90,7 @@ def _map_stock_type(self, stock_type: int) -> StockType:
6890
raise ValueError(f"Invalid stock type: {stock_type}. Must be 1 (STOCKS), 2 (ETF).")
6991
return STOCK_MAP[stock_type]
7092

71-
def _convert_to_proto_stock_list(self, stock_list):
93+
def _convert_to_proto_stock_list(self, stock_list: ListType[Stock]):
7294
return [
7395
stock_pb2.Stock(
7496
id=stock.id,

src/index.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dotenv import load_dotenv
66
from handler.stock import StockService
77
from adapters.stock import StockRepository
8+
from adapters.portfolio import PortfolioRepository
89
from usecase.stock import StockUsecase
910

1011

@@ -14,7 +15,8 @@
1415
def serve():
1516
client = MongoClient("mongodb://localhost:27017")
1617
stock_repo = StockRepository(client, "stock_db")
17-
stock_usecase = StockUsecase(stock_repo)
18+
portfolio_repo = PortfolioRepository(client, "stock_db")
19+
stock_usecase = StockUsecase(stock_repo, portfolio_repo)
1820
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
1921
stock_pb2_grpc.add_StockServiceServicer_to_server(StockService(stock_usecase), server)
2022
server.add_insecure_port("[::]:50051")

src/proto/stock.proto

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,20 @@ message ListResp {
5656
repeated Stock stock_list = 1 [json_name = "stock_list"];
5757
}
5858

59+
message GetPortfolioInfoReq {
60+
int32 user_id = 1 [json_name = "user_id"];
61+
}
62+
63+
message GetPortfolioInfoResp {
64+
int32 user_id = 1 [json_name = "user_id"];
65+
double total_portfolio_value = 2 [json_name = "total_portfolio_value"];
66+
double total_gain = 3 [json_name = "total_gain"];
67+
double roi = 4 [json_name = "roi"];
68+
}
69+
70+
5971
service StockService {
6072
rpc Create (CreateReq) returns (CreateResp) {}
61-
6273
rpc List (ListReq) returns (ListResp) {}
74+
rpc GetPortfolioInfo (GetPortfolioInfoReq) returns (GetPortfolioInfoResp) {}
6375
}

src/proto/stock_pb2.py

Lines changed: 7 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/proto/stock_pb2_grpc.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def __init__(self, channel):
4444
request_serializer=proto_dot_stock__pb2.ListReq.SerializeToString,
4545
response_deserializer=proto_dot_stock__pb2.ListResp.FromString,
4646
_registered_method=True)
47+
self.GetPortfolioInfo = channel.unary_unary(
48+
'/stock.StockService/GetPortfolioInfo',
49+
request_serializer=proto_dot_stock__pb2.GetPortfolioInfoReq.SerializeToString,
50+
response_deserializer=proto_dot_stock__pb2.GetPortfolioInfoResp.FromString,
51+
_registered_method=True)
4752

4853

4954
class StockServiceServicer(object):
@@ -61,6 +66,12 @@ def List(self, request, context):
6166
context.set_details('Method not implemented!')
6267
raise NotImplementedError('Method not implemented!')
6368

69+
def GetPortfolioInfo(self, request, context):
70+
"""Missing associated documentation comment in .proto file."""
71+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
72+
context.set_details('Method not implemented!')
73+
raise NotImplementedError('Method not implemented!')
74+
6475

6576
def add_StockServiceServicer_to_server(servicer, server):
6677
rpc_method_handlers = {
@@ -74,6 +85,11 @@ def add_StockServiceServicer_to_server(servicer, server):
7485
request_deserializer=proto_dot_stock__pb2.ListReq.FromString,
7586
response_serializer=proto_dot_stock__pb2.ListResp.SerializeToString,
7687
),
88+
'GetPortfolioInfo': grpc.unary_unary_rpc_method_handler(
89+
servicer.GetPortfolioInfo,
90+
request_deserializer=proto_dot_stock__pb2.GetPortfolioInfoReq.FromString,
91+
response_serializer=proto_dot_stock__pb2.GetPortfolioInfoResp.SerializeToString,
92+
),
7793
}
7894
generic_handler = grpc.method_handlers_generic_handler(
7995
'stock.StockService', rpc_method_handlers)
@@ -138,3 +154,30 @@ def List(request,
138154
timeout,
139155
metadata,
140156
_registered_method=True)
157+
158+
@staticmethod
159+
def GetPortfolioInfo(request,
160+
target,
161+
options=(),
162+
channel_credentials=None,
163+
call_credentials=None,
164+
insecure=False,
165+
compression=None,
166+
wait_for_ready=None,
167+
timeout=None,
168+
metadata=None):
169+
return grpc.experimental.unary_unary(
170+
request,
171+
target,
172+
'/stock.StockService/GetPortfolioInfo',
173+
proto_dot_stock__pb2.GetPortfolioInfoReq.SerializeToString,
174+
proto_dot_stock__pb2.GetPortfolioInfoResp.FromString,
175+
options,
176+
channel_credentials,
177+
insecure,
178+
call_credentials,
179+
compression,
180+
wait_for_ready,
181+
timeout,
182+
metadata,
183+
_registered_method=True)

src/tests/test_stock_handler.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from handler.stock import StockService
77
from usecase.base import AbstractStockUsecase
88
from domain.stock import CreateStock, Stock
9+
from domain.portfolio import PortfolioInfo
910
from domain.enum import ActionType, StockType
1011

1112

@@ -258,3 +259,62 @@ def test_internal_error(self, mock_stock_usecase, mock_context, valid_request):
258259
mock_context.set_code.assert_called_once_with(grpc.StatusCode.INTERNAL)
259260
mock_context.set_details.assert_called_once_with("Internal server error")
260261
mock_stock_usecase.list.assert_called_once_with(1)
262+
263+
264+
class TestStockServiceGetPortfolioInfo:
265+
# Fixture to create a mock stock_usecase
266+
@pytest.fixture
267+
def mock_stock_usecase(self):
268+
usecase = Mock(spec=AbstractStockUsecase)
269+
usecase.get_portfolio_info.return_value = PortfolioInfo(
270+
user_id=1,
271+
total_portfolio_value=2500.0,
272+
total_gain=500.0,
273+
roi=25.0,
274+
)
275+
return usecase
276+
277+
# Fixture to create a mock gRPC context
278+
@pytest.fixture
279+
def mock_context(self):
280+
context = Mock()
281+
context.set_code = Mock()
282+
context.set_details = Mock()
283+
return context
284+
285+
# Fixture to create a valid gRPC request
286+
@pytest.fixture
287+
def valid_request(self):
288+
request = Mock()
289+
request.user_id = 1
290+
return request
291+
292+
def test_success(self, mock_stock_usecase, mock_context, valid_request):
293+
# Arrange
294+
service = StockService(mock_stock_usecase)
295+
296+
# Action
297+
response = service.GetPortfolioInfo(valid_request, mock_context)
298+
299+
# Assertion
300+
assert isinstance(response, stock_pb2.GetPortfolioInfoResp)
301+
assert response.user_id == 1
302+
assert response.total_portfolio_value == 2500.0
303+
assert response.total_gain == 500.0
304+
assert response.roi == 25.0
305+
mock_stock_usecase.get_portfolio_info.assert_called_once_with(user_id=1)
306+
mock_context.set_code.assert_not_called()
307+
mock_context.set_details.assert_not_called()
308+
309+
def test_internal_error(self, mock_stock_usecase, mock_context, valid_request):
310+
# Arrange
311+
service = StockService(mock_stock_usecase)
312+
mock_stock_usecase.get_portfolio_info.side_effect = Exception("Database error") # Simulate internal error
313+
314+
# Act/Assertion
315+
with pytest.raises(grpc.RpcError) as exc_info:
316+
service.GetPortfolioInfo(valid_request, mock_context)
317+
assert str(exc_info.value) == "Internal server error"
318+
mock_context.set_code.assert_called_once_with(grpc.StatusCode.INTERNAL)
319+
mock_context.set_details.assert_called_once_with("Internal server error")
320+
mock_stock_usecase.get_portfolio_info.assert_called_once_with(user_id=1)

src/usecase/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from abc import ABC, abstractmethod
33

44
from domain.stock import CreateStock, Stock
5+
from domain.portfolio import PortfolioInfo
56

67

78
class AbstractStockUsecase(ABC):
@@ -11,3 +12,6 @@ def create(self, stock: CreateStock) -> str:
1112

1213
def list(self, user_id: int) -> List[Stock]:
1314
"""List all stock by user id"""
15+
16+
def get_portfolio_info(self, user_id: int) -> PortfolioInfo:
17+
"""Get portfolio info"""

0 commit comments

Comments
 (0)