Skip to content

Commit d2cd502

Browse files
eyo-chenEyo Chen
andauthored
Feat/add get stock info list handlre (#22)
* feat: add proto * refactor: renaming * feat: add handler * test: add unit testing --------- Co-authored-by: Eyo Chen <eyo.chen@amazingtalker.com>
1 parent 48dc367 commit d2cd502

8 files changed

Lines changed: 259 additions & 32 deletions

File tree

src/handler/stock.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import proto.stock_pb2 as stock_pb2
66
import proto.stock_pb2_grpc as stock_pb2_grpc
77
from usecase.base import AbstractStockUsecase
8-
from domain.stock import CreateStock, Stock
8+
from domain.stock import CreateStock, Stock, StockInfo
99
from domain.enum import ActionType, ACTION_MAP, StockType, STOCK_MAP
1010

1111

@@ -80,6 +80,22 @@ def GetPortfolioInfo(self, request, context):
8080
context.set_details("Internal server error")
8181
raise grpc.RpcError("Internal server error")
8282

83+
def GetStockInfo(self, request, context):
84+
try:
85+
user_id = request.user_id
86+
stock_info = self.stock_usecase.get_stock_info(user_id=user_id)
87+
88+
return self._convert_to_proto_stock_info(stock_info=stock_info)
89+
except Exception as e:
90+
logging.error(
91+
"Failed to get stock info for user_id=%s: %s",
92+
request.user_id,
93+
str(e),
94+
)
95+
context.set_code(grpc.StatusCode.INTERNAL)
96+
context.set_details("Internal server error")
97+
raise grpc.RpcError("Internal server error")
98+
8399
def _map_action_type(self, action: int) -> ActionType:
84100
if action not in ACTION_MAP:
85101
raise ValueError(f"Invalid action type: {action}. Must be 1 (BUY), 2 (SELL), or 3 (TRANSFER).")
@@ -105,3 +121,22 @@ def _convert_to_proto_stock_list(self, stock_list: ListType[Stock]):
105121
)
106122
for stock in stock_list
107123
]
124+
125+
def _convert_to_proto_stock_info(self, stock_info: StockInfo):
126+
return stock_pb2.GetStockInfoResp(
127+
stocks=self._convert_to_proto_stock_info_list(stock_info[StockType.STOCKS.value]),
128+
etf=self._convert_to_proto_stock_info_list(stock_info[StockType.ETF.value]),
129+
cash=self._convert_to_proto_stock_info_list(stock_info["CASH"]),
130+
)
131+
132+
def _convert_to_proto_stock_info_list(self, stock_info_list: ListType[StockInfo]):
133+
return [
134+
stock_pb2.StockInfo(
135+
symbol=stock_info.symbol,
136+
quantity=stock_info.quantity,
137+
price=stock_info.price,
138+
avg_cost=stock_info.avg_cost,
139+
percentage=stock_info.percentage,
140+
)
141+
for stock_info in stock_info_list
142+
]

src/proto/stock.proto

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,21 @@ message Stock {
3333
google.protobuf.Timestamp updated_at = 9 [json_name = "updated_at"];
3434
}
3535

36+
message StockInfo {
37+
string symbol = 1 [json_name = "symbol"];
38+
int32 quantity = 2 [json_name = "quantity"];
39+
double price = 3 [json_name = "price"];
40+
double avg_cost = 4 [json_name = "avg_cost"];
41+
double percentage = 5 [json_name = "percentage"];
42+
}
43+
3644
message CreateReq {
3745
int32 user_id = 1 [json_name = "user_id"];
3846
string symbol = 2 [json_name = "symbol"];
3947
double price = 3 [json_name = "price"];
4048
int32 quantity = 4 [json_name = "quantity"];
41-
Action.Type action = 5 [json_name = "action"]; // add validation rules
42-
StockType.Type stock_type = 6 [json_name = "stock_type"]; // add validation rules
49+
Action.Type action = 5 [json_name = "action"];
50+
StockType.Type stock_type = 6 [json_name = "stock_type"];
4351
google.protobuf.Timestamp created_at = 7 [json_name = "created_at"];
4452
google.protobuf.Timestamp updated_at = 8 [json_name = "updated_at"];
4553
}
@@ -67,9 +75,19 @@ message GetPortfolioInfoResp {
6775
double roi = 4 [json_name = "roi"];
6876
}
6977

78+
message GetStockInfoReq {
79+
int32 user_id = 1 [json_name = "user_id"];
80+
}
81+
82+
message GetStockInfoResp {
83+
repeated StockInfo stocks = 1 [json_name = "STOCKS"];
84+
repeated StockInfo etf = 2 [json_name = "ETF"];
85+
repeated StockInfo cash = 3 [json_name = "CASH"];
86+
}
7087

7188
service StockService {
7289
rpc Create (CreateReq) returns (CreateResp) {}
7390
rpc List (ListReq) returns (ListResp) {}
7491
rpc GetPortfolioInfo (GetPortfolioInfoReq) returns (GetPortfolioInfoResp) {}
92+
rpc GetStockInfo (GetStockInfoReq) returns (GetStockInfoResp) {}
7593
}

src/proto/stock_pb2.py

Lines changed: 21 additions & 15 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
@@ -49,6 +49,11 @@ def __init__(self, channel):
4949
request_serializer=proto_dot_stock__pb2.GetPortfolioInfoReq.SerializeToString,
5050
response_deserializer=proto_dot_stock__pb2.GetPortfolioInfoResp.FromString,
5151
_registered_method=True)
52+
self.GetStockInfo = channel.unary_unary(
53+
'/stock.StockService/GetStockInfo',
54+
request_serializer=proto_dot_stock__pb2.GetStockInfoReq.SerializeToString,
55+
response_deserializer=proto_dot_stock__pb2.GetStockInfoResp.FromString,
56+
_registered_method=True)
5257

5358

5459
class StockServiceServicer(object):
@@ -72,6 +77,12 @@ def GetPortfolioInfo(self, request, context):
7277
context.set_details('Method not implemented!')
7378
raise NotImplementedError('Method not implemented!')
7479

80+
def GetStockInfo(self, request, context):
81+
"""Missing associated documentation comment in .proto file."""
82+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
83+
context.set_details('Method not implemented!')
84+
raise NotImplementedError('Method not implemented!')
85+
7586

7687
def add_StockServiceServicer_to_server(servicer, server):
7788
rpc_method_handlers = {
@@ -90,6 +101,11 @@ def add_StockServiceServicer_to_server(servicer, server):
90101
request_deserializer=proto_dot_stock__pb2.GetPortfolioInfoReq.FromString,
91102
response_serializer=proto_dot_stock__pb2.GetPortfolioInfoResp.SerializeToString,
92103
),
104+
'GetStockInfo': grpc.unary_unary_rpc_method_handler(
105+
servicer.GetStockInfo,
106+
request_deserializer=proto_dot_stock__pb2.GetStockInfoReq.FromString,
107+
response_serializer=proto_dot_stock__pb2.GetStockInfoResp.SerializeToString,
108+
),
93109
}
94110
generic_handler = grpc.method_handlers_generic_handler(
95111
'stock.StockService', rpc_method_handlers)
@@ -181,3 +197,30 @@ def GetPortfolioInfo(request,
181197
timeout,
182198
metadata,
183199
_registered_method=True)
200+
201+
@staticmethod
202+
def GetStockInfo(request,
203+
target,
204+
options=(),
205+
channel_credentials=None,
206+
call_credentials=None,
207+
insecure=False,
208+
compression=None,
209+
wait_for_ready=None,
210+
timeout=None,
211+
metadata=None):
212+
return grpc.experimental.unary_unary(
213+
request,
214+
target,
215+
'/stock.StockService/GetStockInfo',
216+
proto_dot_stock__pb2.GetStockInfoReq.SerializeToString,
217+
proto_dot_stock__pb2.GetStockInfoResp.FromString,
218+
options,
219+
channel_credentials,
220+
insecure,
221+
call_credentials,
222+
compression,
223+
wait_for_ready,
224+
timeout,
225+
metadata,
226+
_registered_method=True)

src/tests/test_stock_handler.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from unittest.mock import Mock
66
from handler.stock import StockService
77
from usecase.base import AbstractStockUsecase
8-
from domain.stock import CreateStock, Stock
8+
from domain.stock import CreateStock, Stock, StockInfo
99
from domain.portfolio import PortfolioInfo
1010
from domain.enum import ActionType, StockType
1111

@@ -318,3 +318,126 @@ def test_internal_error(self, mock_stock_usecase, mock_context, valid_request):
318318
mock_context.set_code.assert_called_once_with(grpc.StatusCode.INTERNAL)
319319
mock_context.set_details.assert_called_once_with("Internal server error")
320320
mock_stock_usecase.get_portfolio_info.assert_called_once_with(user_id=1)
321+
322+
323+
class TestStockServiceGetStockInfo:
324+
# Fixture to create a mock stock_usecase
325+
@pytest.fixture
326+
def mock_stock_usecase(self):
327+
usecase = Mock(spec=AbstractStockUsecase)
328+
usecase.get_stock_info.return_value = {
329+
StockType.STOCKS.value: [
330+
StockInfo(
331+
symbol="AAPL",
332+
quantity=10,
333+
price=100.0,
334+
avg_cost=95.0,
335+
percentage=5.26,
336+
),
337+
StockInfo(
338+
symbol="GOOGL",
339+
quantity=5,
340+
price=1500.0,
341+
avg_cost=1400.0,
342+
percentage=7.14,
343+
),
344+
],
345+
StockType.ETF.value: [
346+
StockInfo(
347+
symbol="SPY",
348+
quantity=20,
349+
price=400.0,
350+
avg_cost=390.0,
351+
percentage=2.56,
352+
),
353+
],
354+
"CASH": [
355+
StockInfo(
356+
symbol="USD",
357+
quantity=1,
358+
price=1000.0,
359+
avg_cost=1.0,
360+
percentage=0.0,
361+
),
362+
],
363+
}
364+
return usecase
365+
366+
# Fixture to create a mock gRPC context
367+
@pytest.fixture
368+
def mock_context(self):
369+
context = Mock()
370+
context.set_code = Mock()
371+
context.set_details = Mock()
372+
return context
373+
374+
# Fixture to create a valid gRPC request
375+
@pytest.fixture
376+
def valid_request(self):
377+
request = Mock()
378+
request.user_id = 1
379+
return request
380+
381+
def test_success(self, mock_stock_usecase, mock_context, valid_request):
382+
# Arrange
383+
service = StockService(mock_stock_usecase)
384+
385+
expected_result = stock_pb2.GetStockInfoResp(
386+
stocks=[
387+
stock_pb2.StockInfo(
388+
symbol="AAPL",
389+
quantity=10,
390+
price=100.0,
391+
avg_cost=95.0,
392+
percentage=5.26,
393+
),
394+
stock_pb2.StockInfo(
395+
symbol="GOOGL",
396+
quantity=5,
397+
price=1500.0,
398+
avg_cost=1400.0,
399+
percentage=7.14,
400+
),
401+
],
402+
etf=[
403+
stock_pb2.StockInfo(
404+
symbol="SPY",
405+
quantity=20,
406+
price=400.0,
407+
avg_cost=390.0,
408+
percentage=2.56,
409+
),
410+
],
411+
cash=[
412+
stock_pb2.StockInfo(
413+
symbol="USD",
414+
quantity=1,
415+
price=1000.0,
416+
avg_cost=1.0,
417+
percentage=0.0,
418+
),
419+
],
420+
)
421+
422+
# Action
423+
response = service.GetStockInfo(valid_request, mock_context)
424+
425+
# Assertion
426+
assert isinstance(response, stock_pb2.GetStockInfoResp)
427+
assert response == expected_result
428+
mock_stock_usecase.get_stock_info.assert_called_once_with(user_id=1)
429+
mock_context.set_code.assert_not_called()
430+
mock_context.set_details.assert_not_called()
431+
432+
def test_internal_error(self, mock_stock_usecase, mock_context, valid_request):
433+
# Arrange
434+
service = StockService(mock_stock_usecase)
435+
mock_stock_usecase.get_stock_info.side_effect = Exception("Database error") # Simulate internal error
436+
437+
# Act/Assertion
438+
with pytest.raises(grpc.RpcError) as exc_info:
439+
service.GetStockInfo(valid_request, mock_context)
440+
assert str(exc_info.value) == "Internal server error"
441+
mock_context.set_code.assert_called_once_with(grpc.StatusCode.INTERNAL)
442+
mock_context.set_details.assert_called_once_with("Internal server error")
443+
mock_stock_usecase.get_stock_info.assert_called_once_with(user_id=1)

0 commit comments

Comments
 (0)