From d5ea15be30851e4afd49be2035adcddf99a41e18 Mon Sep 17 00:00:00 2001 From: ivangorbachenko Date: Wed, 18 Feb 2026 14:46:30 +0200 Subject: [PATCH 01/10] Implement gRPC communication between orchestrator and fraud_detection, fix JSON parsing --- fraud_detection/requirements.txt | 6 +- fraud_detection/src/app.py | 75 +++++++---- orchestrator/requirements.txt | 6 +- orchestrator/src/app.py | 119 +++++++++--------- utils/pb/__init__.py | 0 .../pb/fraud_detection/fraud_detection.proto | 15 +-- .../pb/fraud_detection/fraud_detection_pb2.py | 34 +++-- .../fraud_detection/fraud_detection_pb2.pyi | 17 --- .../fraud_detection_pb2_grpc.py | 75 +++++++---- 9 files changed, 202 insertions(+), 145 deletions(-) create mode 100644 utils/pb/__init__.py delete mode 100644 utils/pb/fraud_detection/fraud_detection_pb2.pyi diff --git a/fraud_detection/requirements.txt b/fraud_detection/requirements.txt index a80eedef7..a806c0fd2 100644 --- a/fraud_detection/requirements.txt +++ b/fraud_detection/requirements.txt @@ -1,4 +1,4 @@ -grpcio==1.60.0 -grpcio-tools==1.60.0 -protobuf==4.25.2 +grpcio==1.78.0 +grpcio-tools==1.78.0 +protobuf>=5.26.1 watchdog==6.0.0 diff --git a/fraud_detection/src/app.py b/fraud_detection/src/app.py index b2f1d2fce..a71a782e6 100644 --- a/fraud_detection/src/app.py +++ b/fraud_detection/src/app.py @@ -1,45 +1,72 @@ import sys import os +import json +import re +import logging # This set of lines are needed to import the gRPC stubs. # The path of the stubs is relative to the current file, or absolute inside the container. # Change these lines only if strictly needed. FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") -fraud_detection_grpc_path = os.path.abspath(os.path.join(FILE, '../../../utils/pb/fraud_detection')) -sys.path.insert(0, fraud_detection_grpc_path) -import fraud_detection_pb2 as fraud_detection -import fraud_detection_pb2_grpc as fraud_detection_grpc +pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +sys.path.insert(0, pb_root) + +from fraud_detection import fraud_detection_pb2 as fd_pb2 +from fraud_detection import fraud_detection_pb2_grpc as fd_grpc import grpc from concurrent import futures +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("fraud_detection") + # Create a class to define the server functions, derived from # fraud_detection_pb2_grpc.HelloServiceServicer -class HelloService(fraud_detection_grpc.HelloServiceServicer): - # Create an RPC function to say hello - def SayHello(self, request, context): - # Create a HelloResponse object - response = fraud_detection.HelloResponse() - # Set the greeting field of the response object - response.greeting = "Hello, " + request.name - # Print the greeting message - print(response.greeting) - # Return the response object - return response +class FraudDetectionService(fd_grpc.FraudDetectionServiceServicer): + def CheckFraud(self, request, context): + log.info("CheckFraud called") + + try: + order = json.loads(request.order_json) + except Exception: + return fd_pb2.FraudResponse(fraud_detected=True, reason="Invalid JSON") + + items = order.get("items", []) + user = order.get("user", {}) or {} + credit = order.get("creditCard", {}) or {} + + # Dummy fraud rules + # 1) Large quantity + total_qty = 0 + for it in items: + try: + total_qty += int(it.get("quantity", 0)) + except Exception: + pass + if total_qty >= 50: + return fd_pb2.FraudResponse(fraud_detected=True, reason="Too many items") + + # 2) Missing user info + if not user.get("name") or not user.get("contact"): + return fd_pb2.FraudResponse(fraud_detected=True, reason="Missing user info") + + # 3) Weird card number format (very basic) + number = str(credit.get("number", "")) + if number and not re.fullmatch(r"\d{13,19}", number): + return fd_pb2.FraudResponse(fraud_detected=True, reason="Suspicious card number") + + return fd_pb2.FraudResponse(fraud_detected=False, reason="OK") def serve(): - # Create a gRPC server - server = grpc.server(futures.ThreadPoolExecutor()) - # Add HelloService - fraud_detection_grpc.add_HelloServiceServicer_to_server(HelloService(), server) - # Listen on port 50051 + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + fd_grpc.add_FraudDetectionServiceServicer_to_server(FraudDetectionService(), server) + port = "50051" server.add_insecure_port("[::]:" + port) - # Start the server server.start() - print("Server started. Listening on port 50051.") - # Keep thread alive + log.info("Fraud detection service started on port %s", port) server.wait_for_termination() -if __name__ == '__main__': + +if __name__ == "__main__": serve() \ No newline at end of file diff --git a/orchestrator/requirements.txt b/orchestrator/requirements.txt index 5ba8e254b..99265eea2 100644 --- a/orchestrator/requirements.txt +++ b/orchestrator/requirements.txt @@ -1,12 +1,12 @@ blinker==1.7.0 click==8.1.7 Flask==3.0.0 -grpcio==1.60.0 -grpcio-tools==1.60.0 +grpcio==1.78.0 +grpcio-tools==1.78.0 itsdangerous==2.1.2 Jinja2==3.1.3 MarkupSafe==2.1.3 -protobuf==4.25.2 +protobuf>=5.26.1 Werkzeug==3.0.1 Flask-CORS==4.0.0 watchdog==6.0.0 \ No newline at end of file diff --git a/orchestrator/src/app.py b/orchestrator/src/app.py index 62d5d0662..4f4e7b6f5 100644 --- a/orchestrator/src/app.py +++ b/orchestrator/src/app.py @@ -1,75 +1,80 @@ -import sys import os - -# This set of lines are needed to import the gRPC stubs. -# The path of the stubs is relative to the current file, or absolute inside the container. -# Change these lines only if strictly needed. -FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") -fraud_detection_grpc_path = os.path.abspath(os.path.join(FILE, '../../../utils/pb/fraud_detection')) -sys.path.insert(0, fraud_detection_grpc_path) -import fraud_detection_pb2 as fraud_detection -import fraud_detection_pb2_grpc as fraud_detection_grpc - +import sys +import json import grpc -def greet(name='you'): - # Establish a connection with the fraud-detection gRPC service. - with grpc.insecure_channel('fraud_detection:50051') as channel: - # Create a stub object. - stub = fraud_detection_grpc.HelloServiceStub(channel) - # Call the service through the stub object. - response = stub.SayHello(fraud_detection.HelloRequest(name=name)) - return response.greeting - -# Import Flask. -# Flask is a web framework for Python. -# It allows you to build a web application quickly. -# For more information, see https://flask.palletsprojects.com/en/latest/ -from flask import Flask, request +from flask import Flask, request, jsonify from flask_cors import CORS -import json -# Create a simple Flask app. +# Import gRPC generated stubs +FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") +pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +sys.path.insert(0, pb_root) + +from fraud_detection import fraud_detection_pb2 as fd_pb2 +from fraud_detection import fraud_detection_pb2_grpc as fd_grpc + +# Flask app setup app = Flask(__name__) -# Enable CORS for the app. -CORS(app, resources={r'/*': {'origins': '*'}}) +CORS(app, resources={r"/*": {"origins": "*"}}) -# Define a GET endpoint. -@app.route('/', methods=['GET']) -def index(): + +def call_fraud_detection(order_dict): """ - Responds with 'Hello, [name]' when a GET request is made to '/' endpoint. + Calls fraud_detection gRPC service and returns (fraud_detected: bool, reason: str) """ - # Test the fraud-detection gRPC service. - response = greet(name='orchestrator') - # Return the response. - return response + with grpc.insecure_channel("fraud_detection:50051") as channel: + stub = fd_grpc.FraudDetectionServiceStub(channel) + req = fd_pb2.OrderRequest(order_json=json.dumps(order_dict)) + resp = stub.CheckFraud(req, timeout=3) + return resp.fraud_detected, resp.reason -@app.route('/checkout', methods=['POST']) + +@app.route("/", methods=["GET"]) +def index(): + # simple health check endpoint + return "Orchestrator is running", 200 + + +@app.route("/checkout", methods=["POST"]) def checkout(): - """ - Responds with a JSON object containing the order ID, status, and suggested books. - """ - # Get request object data to json - request_data = json.loads(request.data) - # Print request object data - print("Request Data:", request_data.get('items')) + print("CONTENT-TYPE:", request.content_type) + print("RAW DATA:", request.data[:500]) + # Parse JSON safely + request_data = request.get_json(silent=True) + if request_data is None and request.data: + try: + request_data = json.loads(request.data.decode("utf-8")) + except Exception: + request_data = None + + print("FULL REQUEST:", request_data) + if request_data is None: + return jsonify({"error": {"message": "Invalid or missing JSON body"}}), 400 + + items = request_data.get("items") + if not isinstance(items, list) or len(items) == 0: + return jsonify({"error": {"message": "items must be a non-empty list"}}), 400 + + print("Request items:", items) + + # Call fraud detection + fraud_detected, fraud_reason = call_fraud_detection(request_data) + print("Fraud result:", fraud_detected, fraud_reason) + + approved = not fraud_detected - # Dummy response following the provided YAML specification for the bookstore order_status_response = { - 'orderId': '12345', - 'status': 'Order Approved', - 'suggestedBooks': [ - {'bookId': '123', 'title': 'The Best Book', 'author': 'Author 1'}, - {'bookId': '456', 'title': 'The Second Best Book', 'author': 'Author 2'} + "orderId": "12345", + "status": "Order Approved" if approved else "Order Rejected", + "suggestedBooks": [] if not approved else [ + {"bookId": "123", "title": "The Best Book", "author": "Author 1"}, + {"bookId": "456", "title": "The Second Best Book", "author": "Author 2"} ] } - return order_status_response + return jsonify(order_status_response), 200 -if __name__ == '__main__': - # Run the app in debug mode to enable hot reloading. - # This is useful for development. - # The default port is 5000. - app.run(host='0.0.0.0') +if __name__ == "__main__": + app.run(host="0.0.0.0") diff --git a/utils/pb/__init__.py b/utils/pb/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/utils/pb/fraud_detection/fraud_detection.proto b/utils/pb/fraud_detection/fraud_detection.proto index db20211d7..6c9cd1182 100644 --- a/utils/pb/fraud_detection/fraud_detection.proto +++ b/utils/pb/fraud_detection/fraud_detection.proto @@ -1,15 +1,16 @@ syntax = "proto3"; -package hello; +package fraud_detection; -service HelloService { - rpc SayHello (HelloRequest) returns (HelloResponse); +service FraudDetectionService { + rpc CheckFraud (OrderRequest) returns (FraudResponse); } -message HelloRequest { - string name = 1; +message OrderRequest { + string order_json = 1; } -message HelloResponse { - string greeting = 1; +message FraudResponse { + bool fraud_detected = 1; + string reason = 2; } diff --git a/utils/pb/fraud_detection/fraud_detection_pb2.py b/utils/pb/fraud_detection/fraud_detection_pb2.py index cdd0bcae8..b4da1f51f 100644 --- a/utils/pb/fraud_detection/fraud_detection_pb2.py +++ b/utils/pb/fraud_detection/fraud_detection_pb2.py @@ -1,12 +1,22 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! -# source: fraud_detection.proto -# Protobuf Python Version: 4.25.0 +# NO CHECKED-IN PROTOBUF GENCODE +# source: fraud_detection/fraud_detection.proto +# Protobuf Python Version: 6.31.1 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'fraud_detection/fraud_detection.proto' +) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -14,17 +24,17 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x66raud_detection.proto\x12\x05hello\"\x1c\n\x0cHelloRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"!\n\rHelloResponse\x12\x10\n\x08greeting\x18\x01 \x01(\t2E\n\x0cHelloService\x12\x35\n\x08SayHello\x12\x13.hello.HelloRequest\x1a\x14.hello.HelloResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%fraud_detection/fraud_detection.proto\x12\x0f\x66raud_detection\"\"\n\x0cOrderRequest\x12\x12\n\norder_json\x18\x01 \x01(\t\"7\n\rFraudResponse\x12\x16\n\x0e\x66raud_detected\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t2d\n\x15\x46raudDetectionService\x12K\n\nCheckFraud\x12\x1d.fraud_detection.OrderRequest\x1a\x1e.fraud_detection.FraudResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'fraud_detection_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - DESCRIPTOR._options = None - _globals['_HELLOREQUEST']._serialized_start=32 - _globals['_HELLOREQUEST']._serialized_end=60 - _globals['_HELLORESPONSE']._serialized_start=62 - _globals['_HELLORESPONSE']._serialized_end=95 - _globals['_HELLOSERVICE']._serialized_start=97 - _globals['_HELLOSERVICE']._serialized_end=166 +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'fraud_detection.fraud_detection_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_ORDERREQUEST']._serialized_start=58 + _globals['_ORDERREQUEST']._serialized_end=92 + _globals['_FRAUDRESPONSE']._serialized_start=94 + _globals['_FRAUDRESPONSE']._serialized_end=149 + _globals['_FRAUDDETECTIONSERVICE']._serialized_start=151 + _globals['_FRAUDDETECTIONSERVICE']._serialized_end=251 # @@protoc_insertion_point(module_scope) diff --git a/utils/pb/fraud_detection/fraud_detection_pb2.pyi b/utils/pb/fraud_detection/fraud_detection_pb2.pyi deleted file mode 100644 index 30a263856..000000000 --- a/utils/pb/fraud_detection/fraud_detection_pb2.pyi +++ /dev/null @@ -1,17 +0,0 @@ -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Optional as _Optional - -DESCRIPTOR: _descriptor.FileDescriptor - -class HelloRequest(_message.Message): - __slots__ = ("name",) - NAME_FIELD_NUMBER: _ClassVar[int] - name: str - def __init__(self, name: _Optional[str] = ...) -> None: ... - -class HelloResponse(_message.Message): - __slots__ = ("greeting",) - GREETING_FIELD_NUMBER: _ClassVar[int] - greeting: str - def __init__(self, greeting: _Optional[str] = ...) -> None: ... diff --git a/utils/pb/fraud_detection/fraud_detection_pb2_grpc.py b/utils/pb/fraud_detection/fraud_detection_pb2_grpc.py index 4e7a27975..c9921b464 100644 --- a/utils/pb/fraud_detection/fraud_detection_pb2_grpc.py +++ b/utils/pb/fraud_detection/fraud_detection_pb2_grpc.py @@ -1,11 +1,31 @@ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc +import warnings -import fraud_detection_pb2 as fraud__detection__pb2 +from fraud_detection import fraud_detection_pb2 as fraud__detection_dot_fraud__detection__pb2 +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False -class HelloServiceStub(object): +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in fraud_detection/fraud_detection_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class FraudDetectionServiceStub(object): """Missing associated documentation comment in .proto file.""" def __init__(self, channel): @@ -14,42 +34,43 @@ def __init__(self, channel): Args: channel: A grpc.Channel. """ - self.SayHello = channel.unary_unary( - '/hello.HelloService/SayHello', - request_serializer=fraud__detection__pb2.HelloRequest.SerializeToString, - response_deserializer=fraud__detection__pb2.HelloResponse.FromString, - ) + self.CheckFraud = channel.unary_unary( + '/fraud_detection.FraudDetectionService/CheckFraud', + request_serializer=fraud__detection_dot_fraud__detection__pb2.OrderRequest.SerializeToString, + response_deserializer=fraud__detection_dot_fraud__detection__pb2.FraudResponse.FromString, + _registered_method=True) -class HelloServiceServicer(object): +class FraudDetectionServiceServicer(object): """Missing associated documentation comment in .proto file.""" - def SayHello(self, request, context): + def CheckFraud(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') -def add_HelloServiceServicer_to_server(servicer, server): +def add_FraudDetectionServiceServicer_to_server(servicer, server): rpc_method_handlers = { - 'SayHello': grpc.unary_unary_rpc_method_handler( - servicer.SayHello, - request_deserializer=fraud__detection__pb2.HelloRequest.FromString, - response_serializer=fraud__detection__pb2.HelloResponse.SerializeToString, + 'CheckFraud': grpc.unary_unary_rpc_method_handler( + servicer.CheckFraud, + request_deserializer=fraud__detection_dot_fraud__detection__pb2.OrderRequest.FromString, + response_serializer=fraud__detection_dot_fraud__detection__pb2.FraudResponse.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( - 'hello.HelloService', rpc_method_handlers) + 'fraud_detection.FraudDetectionService', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('fraud_detection.FraudDetectionService', rpc_method_handlers) # This class is part of an EXPERIMENTAL API. -class HelloService(object): +class FraudDetectionService(object): """Missing associated documentation comment in .proto file.""" @staticmethod - def SayHello(request, + def CheckFraud(request, target, options=(), channel_credentials=None, @@ -59,8 +80,18 @@ def SayHello(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/hello.HelloService/SayHello', - fraud__detection__pb2.HelloRequest.SerializeToString, - fraud__detection__pb2.HelloResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + return grpc.experimental.unary_unary( + request, + target, + '/fraud_detection.FraudDetectionService/CheckFraud', + fraud__detection_dot_fraud__detection__pb2.OrderRequest.SerializeToString, + fraud__detection_dot_fraud__detection__pb2.FraudResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) From bca33ff5be9338a28b7830ebb8aa6ad03872e9c5 Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 14:36:27 +0200 Subject: [PATCH 02/10] feat : transaction verification --- docker-compose.yaml | 40 +++----- orchestrator/src/app.py | 20 +++- transaction_verification/Dockerfile | 15 +++ transaction_verification/requirements.txt | 4 + transaction_verification/src/app.py | 92 ++++++++++++++++++ utils/pb/transaction_verification/__init__.py | 0 .../transaction_verification.proto | 16 +++ .../transaction_verification_pb2.py | 40 ++++++++ .../transaction_verification_pb2.pyi | 19 ++++ .../transaction_verification_pb2_grpc.py | 97 +++++++++++++++++++ 10 files changed, 315 insertions(+), 28 deletions(-) create mode 100644 transaction_verification/Dockerfile create mode 100644 transaction_verification/requirements.txt create mode 100644 transaction_verification/src/app.py create mode 100644 utils/pb/transaction_verification/__init__.py create mode 100644 utils/pb/transaction_verification/transaction_verification.proto create mode 100644 utils/pb/transaction_verification/transaction_verification_pb2.py create mode 100644 utils/pb/transaction_verification/transaction_verification_pb2.pyi create mode 100644 utils/pb/transaction_verification/transaction_verification_pb2_grpc.py diff --git a/docker-compose.yaml b/docker-compose.yaml index b4a60a537..357229c82 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,59 +1,45 @@ -version: '3' services: frontend: build: - # Use the current directory as the build context - # This allows us to access the files in the current directory inside the Dockerfile context: ./ dockerfile: ./frontend/Dockerfile ports: - # Expose port 8080 on the host, and map port 80 of the container to port 8080 on the host - # Access the application at http://localhost:8080 - "8080:80" volumes: - # Mount the frontend directory - ./frontend/src:/usr/share/nginx/html orchestrator: build: - # Use the current directory as the build context - # This allows us to access the files in the current directory inside the Dockerfile context: ./ - # Use the Dockerfile in the orchestrator directory dockerfile: ./orchestrator/Dockerfile ports: - # Expose port 8081 on the host, and map port 5000 of the container to port 8081 on the host - 8081:5000 environment: - # Pass the environment variables to the container - # The PYTHONUNBUFFERED environment variable ensures that the output from the application is logged to the console - PYTHONUNBUFFERED=TRUE - # The PYTHONFILE environment variable specifies the absolute entry point of the application - # Check app.py in the orchestrator directory to see how this is used - PYTHONFILE=/app/orchestrator/src/app.py volumes: - # Mount the utils directory in the current directory to the /app/utils directory in the container - ./utils:/app/utils - # Mount the orchestrator/src directory in the current directory to the /app/orchestrator/src directory in the container - ./orchestrator/src:/app/orchestrator/src fraud_detection: build: - # Use the current directory as the build context - # This allows us to access the files in the current directory inside the Dockerfile context: ./ - # Use the Dockerfile in the fraud_detection directorys dockerfile: ./fraud_detection/Dockerfile ports: - # Expose port 50051 on the host, and map port 50051 of the container to port 50051 on the host - 50051:50051 environment: - # Pass the environment variables to the container - # The PYTHONUNBUFFERED environment variable ensures that the output from the application is logged to the console - PYTHONUNBUFFERED=TRUE - # The PYTHONFILE environment variable specifies the absolute entry point of the application - # Check app.py in the fraud_detection directory to see how this is used - PYTHONFILE=/app/fraud_detection/src/app.py volumes: - # Mount the utils directory in the current directory to the /app/utils directory in the container - ./utils:/app/utils - # Mount the fraud_detection/src directory in the current directory to the /app/fraud_detection/src directory in the container - - ./fraud_detection/src:/app/fraud_detection/src \ No newline at end of file + - ./fraud_detection/src:/app/fraud_detection/src + transaction_verification: + build: + context: ./ + dockerfile: ./transaction_verification/Dockerfile + ports: + - 50052:50052 + environment: + - PYTHONUNBUFFERED=TRUE + - PYTHONFILE=/app/transaction_verification/src/app.py + volumes: + - ./utils:/app/utils + - ./transaction_verification/src:/app/transaction_verification/src diff --git a/orchestrator/src/app.py b/orchestrator/src/app.py index 4f4e7b6f5..00b04373f 100644 --- a/orchestrator/src/app.py +++ b/orchestrator/src/app.py @@ -13,6 +13,8 @@ from fraud_detection import fraud_detection_pb2 as fd_pb2 from fraud_detection import fraud_detection_pb2_grpc as fd_grpc +from transaction_verification import transaction_verification_pb2 as tv_pb2 +from transaction_verification import transaction_verification_pb2_grpc as tv_grpc # Flask app setup app = Flask(__name__) @@ -30,6 +32,17 @@ def call_fraud_detection(order_dict): return resp.fraud_detected, resp.reason +def call_transaction_verification(order_dict): + """ + Calls transaction_verification gRPC service and returns (is_valid: bool, reason: str) + """ + with grpc.insecure_channel("transaction_verification:50052") as channel: + stub = tv_grpc.TransactionVerificationServiceStub(channel) + req = tv_pb2.TransactionRequest(order_json=json.dumps(order_dict)) + resp = stub.VerifyTransaction(req, timeout=3) + return resp.is_valid, resp.reason + + @app.route("/", methods=["GET"]) def index(): # simple health check endpoint @@ -62,7 +75,12 @@ def checkout(): fraud_detected, fraud_reason = call_fraud_detection(request_data) print("Fraud result:", fraud_detected, fraud_reason) - approved = not fraud_detected + # Call transaction verification + transaction_valid, transaction_reason = call_transaction_verification(request_data) + print("Transaction result:", transaction_valid, transaction_reason) + + # Consolidate results: reject if fraud detected OR transaction invalid + approved = (not fraud_detected) and transaction_valid order_status_response = { "orderId": "12345", diff --git a/transaction_verification/Dockerfile b/transaction_verification/Dockerfile new file mode 100644 index 000000000..497987e02 --- /dev/null +++ b/transaction_verification/Dockerfile @@ -0,0 +1,15 @@ +# Use an official Python runtime as the base image +FROM python:3.11 + +# Set the working directory in the container +# Both the utils and src folders will be mounted as volumes, please see docker-compose.yaml +WORKDIR /app + +# Copy the requirements file to the working directory +COPY ./transaction_verification/requirements.txt . + +# Install the Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Set the command to run the application +CMD python utils/other/hotreload.py "transaction_verification/src/app.py" diff --git a/transaction_verification/requirements.txt b/transaction_verification/requirements.txt new file mode 100644 index 000000000..a806c0fd2 --- /dev/null +++ b/transaction_verification/requirements.txt @@ -0,0 +1,4 @@ +grpcio==1.78.0 +grpcio-tools==1.78.0 +protobuf>=5.26.1 +watchdog==6.0.0 diff --git a/transaction_verification/src/app.py b/transaction_verification/src/app.py new file mode 100644 index 000000000..f983f4ce7 --- /dev/null +++ b/transaction_verification/src/app.py @@ -0,0 +1,92 @@ +import sys +import os +import json +import re +import logging + +# This set of lines are needed to import the gRPC stubs. +# The path of the stubs is relative to the current file, or absolute inside the container. +# Change these lines only if strictly needed. +FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") +pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +sys.path.insert(0, pb_root) + +from transaction_verification import transaction_verification_pb2 as tv_pb2 +from transaction_verification import transaction_verification_pb2_grpc as tv_grpc + +import grpc +from concurrent import futures + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("transaction_verification") + + +class TransactionVerificationService(tv_grpc.TransactionVerificationServiceServicer): + def VerifyTransaction(self, request, context): + log.info("VerifyTransaction called") + + try: + order = json.loads(request.order_json) + except Exception: + return tv_pb2.TransactionResponse(is_valid=False, reason="Invalid JSON") + + items = order.get("items", []) + user = order.get("user", {}) or {} + credit = order.get("creditCard", {}) or {} + + # Validation rules + # 1) Check if items list is not empty + if not items or len(items) == 0: + return tv_pb2.TransactionResponse(is_valid=False, reason="No items in order") + + # 2) Check if user info is complete + if not user.get("name"): + return tv_pb2.TransactionResponse(is_valid=False, reason="Missing user name") + if not user.get("contact"): + return tv_pb2.TransactionResponse(is_valid=False, reason="Missing user contact") + + # 3) Check if credit card info is complete + if not credit.get("number"): + return tv_pb2.TransactionResponse(is_valid=False, reason="Missing credit card number") + if not credit.get("expirationDate"): + return tv_pb2.TransactionResponse(is_valid=False, reason="Missing expiration date") + if not credit.get("cvv"): + return tv_pb2.TransactionResponse(is_valid=False, reason="Missing CVV") + + # 4) Validate credit card number format (13-19 digits) + card_number = str(credit.get("number", "")) + if not re.fullmatch(r"\d{13,19}", card_number): + return tv_pb2.TransactionResponse(is_valid=False, reason="Invalid credit card format") + + # 5) Validate CVV format (3-4 digits) + cvv = str(credit.get("cvv", "")) + if not re.fullmatch(r"\d{3,4}", cvv): + return tv_pb2.TransactionResponse(is_valid=False, reason="Invalid CVV format") + + # 6) Check each item has required fields + for item in items: + if not item.get("name"): + return tv_pb2.TransactionResponse(is_valid=False, reason="Item missing name") + quantity = item.get("quantity") + if quantity is None or not isinstance(quantity, (int, float)) or quantity <= 0: + return tv_pb2.TransactionResponse(is_valid=False, reason="Invalid item quantity") + + # All checks passed + return tv_pb2.TransactionResponse(is_valid=True, reason="Transaction valid") + + +def serve(): + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + tv_grpc.add_TransactionVerificationServiceServicer_to_server( + TransactionVerificationService(), server + ) + + port = "50052" + server.add_insecure_port("[::]:" + port) + server.start() + log.info("Transaction verification service started on port %s", port) + server.wait_for_termination() + + +if __name__ == "__main__": + serve() diff --git a/utils/pb/transaction_verification/__init__.py b/utils/pb/transaction_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/utils/pb/transaction_verification/transaction_verification.proto b/utils/pb/transaction_verification/transaction_verification.proto new file mode 100644 index 000000000..fbd99fff2 --- /dev/null +++ b/utils/pb/transaction_verification/transaction_verification.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package transaction_verification; + +service TransactionVerificationService { + rpc VerifyTransaction (TransactionRequest) returns (TransactionResponse); +} + +message TransactionRequest { + string order_json = 1; +} + +message TransactionResponse { + bool is_valid = 1; + string reason = 2; +} diff --git a/utils/pb/transaction_verification/transaction_verification_pb2.py b/utils/pb/transaction_verification/transaction_verification_pb2.py new file mode 100644 index 000000000..45dfe3fe2 --- /dev/null +++ b/utils/pb/transaction_verification/transaction_verification_pb2.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: transaction_verification/transaction_verification.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'transaction_verification/transaction_verification.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n7transaction_verification/transaction_verification.proto\x12\x18transaction_verification\"(\n\x12TransactionRequest\x12\x12\n\norder_json\x18\x01 \x01(\t\"7\n\x13TransactionResponse\x12\x10\n\x08is_valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t2\x92\x01\n\x1eTransactionVerificationService\x12p\n\x11VerifyTransaction\x12,.transaction_verification.TransactionRequest\x1a-.transaction_verification.TransactionResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'transaction_verification.transaction_verification_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_TRANSACTIONREQUEST']._serialized_start=85 + _globals['_TRANSACTIONREQUEST']._serialized_end=125 + _globals['_TRANSACTIONRESPONSE']._serialized_start=127 + _globals['_TRANSACTIONRESPONSE']._serialized_end=182 + _globals['_TRANSACTIONVERIFICATIONSERVICE']._serialized_start=185 + _globals['_TRANSACTIONVERIFICATIONSERVICE']._serialized_end=331 +# @@protoc_insertion_point(module_scope) diff --git a/utils/pb/transaction_verification/transaction_verification_pb2.pyi b/utils/pb/transaction_verification/transaction_verification_pb2.pyi new file mode 100644 index 000000000..66545baa7 --- /dev/null +++ b/utils/pb/transaction_verification/transaction_verification_pb2.pyi @@ -0,0 +1,19 @@ +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Optional as _Optional + +DESCRIPTOR: _descriptor.FileDescriptor + +class TransactionRequest(_message.Message): + __slots__ = ("order_json",) + ORDER_JSON_FIELD_NUMBER: _ClassVar[int] + order_json: str + def __init__(self, order_json: _Optional[str] = ...) -> None: ... + +class TransactionResponse(_message.Message): + __slots__ = ("is_valid", "reason") + IS_VALID_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + is_valid: bool + reason: str + def __init__(self, is_valid: bool = ..., reason: _Optional[str] = ...) -> None: ... diff --git a/utils/pb/transaction_verification/transaction_verification_pb2_grpc.py b/utils/pb/transaction_verification/transaction_verification_pb2_grpc.py new file mode 100644 index 000000000..f3719b111 --- /dev/null +++ b/utils/pb/transaction_verification/transaction_verification_pb2_grpc.py @@ -0,0 +1,97 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from transaction_verification import transaction_verification_pb2 as transaction__verification_dot_transaction__verification__pb2 + +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in transaction_verification/transaction_verification_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class TransactionVerificationServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.VerifyTransaction = channel.unary_unary( + '/transaction_verification.TransactionVerificationService/VerifyTransaction', + request_serializer=transaction__verification_dot_transaction__verification__pb2.TransactionRequest.SerializeToString, + response_deserializer=transaction__verification_dot_transaction__verification__pb2.TransactionResponse.FromString, + _registered_method=True) + + +class TransactionVerificationServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def VerifyTransaction(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_TransactionVerificationServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'VerifyTransaction': grpc.unary_unary_rpc_method_handler( + servicer.VerifyTransaction, + request_deserializer=transaction__verification_dot_transaction__verification__pb2.TransactionRequest.FromString, + response_serializer=transaction__verification_dot_transaction__verification__pb2.TransactionResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'transaction_verification.TransactionVerificationService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('transaction_verification.TransactionVerificationService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class TransactionVerificationService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def VerifyTransaction(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/transaction_verification.TransactionVerificationService/VerifyTransaction', + transaction__verification_dot_transaction__verification__pb2.TransactionRequest.SerializeToString, + transaction__verification_dot_transaction__verification__pb2.TransactionResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) From 25317c09e60e1134458fea058c45e4b76296aaa4 Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 14:51:16 +0200 Subject: [PATCH 03/10] feat: suggestions --- docker-compose.yaml | 12 +++ orchestrator/src/app.py | 27 +++++- suggestions/Dockerfile | 15 +++ suggestions/requirements.txt | 4 + suggestions/src/app.py | 78 ++++++++++++++++ utils/pb/suggestions/__init__.py | 0 utils/pb/suggestions/suggestions.proto | 21 +++++ utils/pb/suggestions/suggestions_pb2.py | 42 +++++++++ utils/pb/suggestions/suggestions_pb2.pyi | 29 ++++++ utils/pb/suggestions/suggestions_pb2_grpc.py | 97 ++++++++++++++++++++ 10 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 suggestions/Dockerfile create mode 100644 suggestions/requirements.txt create mode 100644 suggestions/src/app.py create mode 100644 utils/pb/suggestions/__init__.py create mode 100644 utils/pb/suggestions/suggestions.proto create mode 100644 utils/pb/suggestions/suggestions_pb2.py create mode 100644 utils/pb/suggestions/suggestions_pb2.pyi create mode 100644 utils/pb/suggestions/suggestions_pb2_grpc.py diff --git a/docker-compose.yaml b/docker-compose.yaml index 357229c82..b431569d1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -43,3 +43,15 @@ services: volumes: - ./utils:/app/utils - ./transaction_verification/src:/app/transaction_verification/src + suggestions: + build: + context: ./ + dockerfile: ./suggestions/Dockerfile + ports: + - 50053:50053 + environment: + - PYTHONUNBUFFERED=TRUE + - PYTHONFILE=/app/suggestions/src/app.py + volumes: + - ./utils:/app/utils + - ./suggestions/src:/app/suggestions/src diff --git a/orchestrator/src/app.py b/orchestrator/src/app.py index 00b04373f..5454fbe0a 100644 --- a/orchestrator/src/app.py +++ b/orchestrator/src/app.py @@ -15,6 +15,8 @@ from fraud_detection import fraud_detection_pb2_grpc as fd_grpc from transaction_verification import transaction_verification_pb2 as tv_pb2 from transaction_verification import transaction_verification_pb2_grpc as tv_grpc +from suggestions import suggestions_pb2 as sg_pb2 +from suggestions import suggestions_pb2_grpc as sg_grpc # Flask app setup app = Flask(__name__) @@ -43,6 +45,20 @@ def call_transaction_verification(order_dict): return resp.is_valid, resp.reason +def call_suggestions(order_dict): + """ + Calls suggestions gRPC service and returns list of book suggestions + """ + with grpc.insecure_channel("suggestions:50053") as channel: + stub = sg_grpc.SuggestionsServiceStub(channel) + req = sg_pb2.SuggestionsRequest(order_json=json.dumps(order_dict)) + resp = stub.GetSuggestions(req, timeout=3) + return [ + {"bookId": book.book_id, "title": book.title, "author": book.author} + for book in resp.books + ] + + @app.route("/", methods=["GET"]) def index(): # simple health check endpoint @@ -82,13 +98,16 @@ def checkout(): # Consolidate results: reject if fraud detected OR transaction invalid approved = (not fraud_detected) and transaction_valid + # Get suggestions only if order approved + suggested_books = [] + if approved: + suggested_books = call_suggestions(request_data) + print("Suggestions result:", len(suggested_books), "books") + order_status_response = { "orderId": "12345", "status": "Order Approved" if approved else "Order Rejected", - "suggestedBooks": [] if not approved else [ - {"bookId": "123", "title": "The Best Book", "author": "Author 1"}, - {"bookId": "456", "title": "The Second Best Book", "author": "Author 2"} - ] + "suggestedBooks": suggested_books } return jsonify(order_status_response), 200 diff --git a/suggestions/Dockerfile b/suggestions/Dockerfile new file mode 100644 index 000000000..c37e98faf --- /dev/null +++ b/suggestions/Dockerfile @@ -0,0 +1,15 @@ +# Use an official Python runtime as the base image +FROM python:3.11 + +# Set the working directory in the container +# Both the utils and src folders will be mounted as volumes, please see docker-compose.yaml +WORKDIR /app + +# Copy the requirements file to the working directory +COPY ./suggestions/requirements.txt . + +# Install the Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Set the command to run the application +CMD python utils/other/hotreload.py "suggestions/src/app.py" diff --git a/suggestions/requirements.txt b/suggestions/requirements.txt new file mode 100644 index 000000000..a806c0fd2 --- /dev/null +++ b/suggestions/requirements.txt @@ -0,0 +1,4 @@ +grpcio==1.78.0 +grpcio-tools==1.78.0 +protobuf>=5.26.1 +watchdog==6.0.0 diff --git a/suggestions/src/app.py b/suggestions/src/app.py new file mode 100644 index 000000000..b1a638f9f --- /dev/null +++ b/suggestions/src/app.py @@ -0,0 +1,78 @@ +import sys +import os +import json +import random +import logging + +# This set of lines are needed to import the gRPC stubs. +# The path of the stubs is relative to the current file, or absolute inside the container. +# Change these lines only if strictly needed. +FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") +pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +sys.path.insert(0, pb_root) + +from suggestions import suggestions_pb2 as sg_pb2 +from suggestions import suggestions_pb2_grpc as sg_grpc + +import grpc +from concurrent import futures + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("suggestions") + +# Static book catalog +BOOK_CATALOG = [ + {"book_id": "101", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}, + {"book_id": "102", "title": "To Kill a Mockingbird", "author": "Harper Lee"}, + {"book_id": "103", "title": "1984", "author": "George Orwell"}, + {"book_id": "104", "title": "Pride and Prejudice", "author": "Jane Austen"}, + {"book_id": "105", "title": "The Catcher in the Rye", "author": "J.D. Salinger"}, + {"book_id": "106", "title": "Animal Farm", "author": "George Orwell"}, + {"book_id": "107", "title": "Lord of the Flies", "author": "William Golding"}, + {"book_id": "108", "title": "Brave New World", "author": "Aldous Huxley"}, + {"book_id": "109", "title": "The Hobbit", "author": "J.R.R. Tolkien"}, + {"book_id": "110", "title": "Fahrenheit 451", "author": "Ray Bradbury"}, +] + + +class SuggestionsService(sg_grpc.SuggestionsServiceServicer): + def GetSuggestions(self, request, context): + log.info("GetSuggestions called") + + try: + order = json.loads(request.order_json) + except Exception: + # Return empty suggestions on invalid JSON + return sg_pb2.SuggestionsResponse(books=[]) + + # Simple logic: return 3 random books from catalog - later can be some user based history + num_suggestions = min(3, len(BOOK_CATALOG)) + suggested_books = random.sample(BOOK_CATALOG, num_suggestions) + + # Protobuf convert + books = [ + sg_pb2.Book( + book_id=book["book_id"], + title=book["title"], + author=book["author"] + ) + for book in suggested_books + ] + + log.info(f"Returning {len(books)} book suggestions") + return sg_pb2.SuggestionsResponse(books=books) + + +def serve(): + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + sg_grpc.add_SuggestionsServiceServicer_to_server(SuggestionsService(), server) + + port = "50053" + server.add_insecure_port("[::]:" + port) + server.start() + log.info("Suggestions service started on port %s", port) + server.wait_for_termination() + + +if __name__ == "__main__": + serve() diff --git a/utils/pb/suggestions/__init__.py b/utils/pb/suggestions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/utils/pb/suggestions/suggestions.proto b/utils/pb/suggestions/suggestions.proto new file mode 100644 index 000000000..07c464c51 --- /dev/null +++ b/utils/pb/suggestions/suggestions.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package suggestions; + +service SuggestionsService { + rpc GetSuggestions (SuggestionsRequest) returns (SuggestionsResponse); +} + +message SuggestionsRequest { + string order_json = 1; +} + +message Book { + string book_id = 1; + string title = 2; + string author = 3; +} + +message SuggestionsResponse { + repeated Book books = 1; +} diff --git a/utils/pb/suggestions/suggestions_pb2.py b/utils/pb/suggestions/suggestions_pb2.py new file mode 100644 index 000000000..c180fbf83 --- /dev/null +++ b/utils/pb/suggestions/suggestions_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: suggestions/suggestions.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'suggestions/suggestions.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1dsuggestions/suggestions.proto\x12\x0bsuggestions\"(\n\x12SuggestionsRequest\x12\x12\n\norder_json\x18\x01 \x01(\t\"6\n\x04\x42ook\x12\x0f\n\x07\x62ook_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\"7\n\x13SuggestionsResponse\x12 \n\x05\x62ooks\x18\x01 \x03(\x0b\x32\x11.suggestions.Book2i\n\x12SuggestionsService\x12S\n\x0eGetSuggestions\x12\x1f.suggestions.SuggestionsRequest\x1a .suggestions.SuggestionsResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'suggestions.suggestions_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_SUGGESTIONSREQUEST']._serialized_start=46 + _globals['_SUGGESTIONSREQUEST']._serialized_end=86 + _globals['_BOOK']._serialized_start=88 + _globals['_BOOK']._serialized_end=142 + _globals['_SUGGESTIONSRESPONSE']._serialized_start=144 + _globals['_SUGGESTIONSRESPONSE']._serialized_end=199 + _globals['_SUGGESTIONSSERVICE']._serialized_start=201 + _globals['_SUGGESTIONSSERVICE']._serialized_end=306 +# @@protoc_insertion_point(module_scope) diff --git a/utils/pb/suggestions/suggestions_pb2.pyi b/utils/pb/suggestions/suggestions_pb2.pyi new file mode 100644 index 000000000..704809e39 --- /dev/null +++ b/utils/pb/suggestions/suggestions_pb2.pyi @@ -0,0 +1,29 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class SuggestionsRequest(_message.Message): + __slots__ = ("order_json",) + ORDER_JSON_FIELD_NUMBER: _ClassVar[int] + order_json: str + def __init__(self, order_json: _Optional[str] = ...) -> None: ... + +class Book(_message.Message): + __slots__ = ("book_id", "title", "author") + BOOK_ID_FIELD_NUMBER: _ClassVar[int] + TITLE_FIELD_NUMBER: _ClassVar[int] + AUTHOR_FIELD_NUMBER: _ClassVar[int] + book_id: str + title: str + author: str + def __init__(self, book_id: _Optional[str] = ..., title: _Optional[str] = ..., author: _Optional[str] = ...) -> None: ... + +class SuggestionsResponse(_message.Message): + __slots__ = ("books",) + BOOKS_FIELD_NUMBER: _ClassVar[int] + books: _containers.RepeatedCompositeFieldContainer[Book] + def __init__(self, books: _Optional[_Iterable[_Union[Book, _Mapping]]] = ...) -> None: ... diff --git a/utils/pb/suggestions/suggestions_pb2_grpc.py b/utils/pb/suggestions/suggestions_pb2_grpc.py new file mode 100644 index 000000000..de82e367e --- /dev/null +++ b/utils/pb/suggestions/suggestions_pb2_grpc.py @@ -0,0 +1,97 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from suggestions import suggestions_pb2 as suggestions_dot_suggestions__pb2 + +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in suggestions/suggestions_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class SuggestionsServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.GetSuggestions = channel.unary_unary( + '/suggestions.SuggestionsService/GetSuggestions', + request_serializer=suggestions_dot_suggestions__pb2.SuggestionsRequest.SerializeToString, + response_deserializer=suggestions_dot_suggestions__pb2.SuggestionsResponse.FromString, + _registered_method=True) + + +class SuggestionsServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def GetSuggestions(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SuggestionsServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'GetSuggestions': grpc.unary_unary_rpc_method_handler( + servicer.GetSuggestions, + request_deserializer=suggestions_dot_suggestions__pb2.SuggestionsRequest.FromString, + response_serializer=suggestions_dot_suggestions__pb2.SuggestionsResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'suggestions.SuggestionsService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('suggestions.SuggestionsService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class SuggestionsService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def GetSuggestions(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/suggestions.SuggestionsService/GetSuggestions', + suggestions_dot_suggestions__pb2.SuggestionsRequest.SerializeToString, + suggestions_dot_suggestions__pb2.SuggestionsResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) From 75a2351496069f4d1c601392483bf4fd3a24718b Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 15:05:19 +0200 Subject: [PATCH 04/10] feat: orchestrator multi-threading - from ~250ms to ~100ms --- orchestrator/src/app.py | 75 +++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/orchestrator/src/app.py b/orchestrator/src/app.py index 5454fbe0a..4db5d350e 100644 --- a/orchestrator/src/app.py +++ b/orchestrator/src/app.py @@ -2,10 +2,15 @@ import sys import json import grpc +import logging +import threading from flask import Flask, request, jsonify from flask_cors import CORS +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("orchestrator") + # Import gRPC generated stubs FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) @@ -87,22 +92,70 @@ def checkout(): print("Request items:", items) - # Call fraud detection - fraud_detected, fraud_reason = call_fraud_detection(request_data) + # Prepare shared storage for thread results + results = {} + + # Define worker functions that store results in the shared dict + def worker_fraud_detection(): + log.info("Thread: Starting fraud detection") + try: + fraud_detected, fraud_reason = call_fraud_detection(request_data) + results['fraud'] = {'detected': fraud_detected, 'reason': fraud_reason} + log.info(f"Thread: Fraud detection completed - detected={fraud_detected}") + except Exception as e: + log.error(f"Thread: Fraud detection failed - {e}") + results['fraud'] = {'detected': True, 'reason': 'Service error'} + + def worker_transaction_verification(): + log.info("Thread: Starting transaction verification") + try: + is_valid, reason = call_transaction_verification(request_data) + results['transaction'] = {'valid': is_valid, 'reason': reason} + log.info(f"Thread: Transaction verification completed - valid={is_valid}") + except Exception as e: + log.error(f"Thread: Transaction verification failed - {e}") + results['transaction'] = {'valid': False, 'reason': 'Service error'} + + def worker_suggestions(): + log.info("Thread: Starting suggestions") + try: + books = call_suggestions(request_data) + results['suggestions'] = {'books': books} + log.info(f"Thread: Suggestions completed - {len(books)} books") + except Exception as e: + log.error(f"Thread: Suggestions failed - {e}") + results['suggestions'] = {'books': []} + + # Create worker threads + thread_fraud = threading.Thread(target=worker_fraud_detection, name="FraudThread") + thread_transaction = threading.Thread(target=worker_transaction_verification, name="TransactionThread") + thread_suggestions = threading.Thread(target=worker_suggestions, name="SuggestionsThread") + + log.info("Starting all worker threads") + thread_fraud.start() + thread_transaction.start() + thread_suggestions.start() + + thread_fraud.join() + thread_transaction.join() + thread_suggestions.join() + log.info("All threads completed") + + # Extract results from shared dict + fraud_detected = results.get('fraud', {}).get('detected', True) + fraud_reason = results.get('fraud', {}).get('reason', 'Unknown') + transaction_valid = results.get('transaction', {}).get('valid', False) + transaction_reason = results.get('transaction', {}).get('reason', 'Unknown') + suggested_books = results.get('suggestions', {}).get('books', []) + print("Fraud result:", fraud_detected, fraud_reason) - - # Call transaction verification - transaction_valid, transaction_reason = call_transaction_verification(request_data) print("Transaction result:", transaction_valid, transaction_reason) + print("Suggestions result:", len(suggested_books), "books") # Consolidate results: reject if fraud detected OR transaction invalid approved = (not fraud_detected) and transaction_valid - - # Get suggestions only if order approved - suggested_books = [] - if approved: - suggested_books = call_suggestions(request_data) - print("Suggestions result:", len(suggested_books), "books") + if not approved: + suggested_books = [] order_status_response = { "orderId": "12345", From e2841e05b16f6f5555d3ddb0bd59c805f762d51e Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 15:17:04 +0200 Subject: [PATCH 05/10] feat: mask sensitive, error handling, service down handling 503 --- orchestrator/src/app.py | 115 +++++++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 24 deletions(-) diff --git a/orchestrator/src/app.py b/orchestrator/src/app.py index 4db5d350e..9dcc5197b 100644 --- a/orchestrator/src/app.py +++ b/orchestrator/src/app.py @@ -28,6 +28,26 @@ CORS(app, resources={r"/*": {"origins": "*"}}) +def mask_sensitive_data(data): + """ + Mask sensitive data + """ + if not isinstance(data, dict): + return data + + masked = data.copy() + + if 'creditCard' in masked and isinstance(masked['creditCard'], dict): + cc = masked['creditCard'].copy() + if 'number' in cc and cc['number']: + cc['number'] = '****' + str(cc['number'])[-4:] if len(str(cc['number'])) >= 4 else '****' + if 'cvv' in cc: + cc['cvv'] = '***' + masked['creditCard'] = cc + + return masked + + def call_fraud_detection(order_dict): """ Calls fraud_detection gRPC service and returns (fraud_detected: bool, reason: str) @@ -72,25 +92,38 @@ def index(): @app.route("/checkout", methods=["POST"]) def checkout(): - print("CONTENT-TYPE:", request.content_type) - print("RAW DATA:", request.data[:500]) + log.info(f"Received checkout request - Content-Type: {request.content_type}") + # Parse JSON safely request_data = request.get_json(silent=True) if request_data is None and request.data: try: request_data = json.loads(request.data.decode("utf-8")) - except Exception: + except Exception as e: + log.error(f"Failed to parse JSON: {e}") request_data = None - print("FULL REQUEST:", request_data) + log.info(f"Parsed request (masked): {mask_sensitive_data(request_data)}") + if request_data is None: - return jsonify({"error": {"message": "Invalid or missing JSON body"}}), 400 + return jsonify({"error": {"code": "INVALID_JSON", "message": "Invalid or missing JSON body"}}), 400 + # Validate required fields according to API contract items = request_data.get("items") if not isinstance(items, list) or len(items) == 0: - return jsonify({"error": {"message": "items must be a non-empty list"}}), 400 + return jsonify({"error": {"code": "INVALID_ITEMS", "message": "items must be a non-empty list"}}), 400 + + # Validate user information + user = request_data.get("user") + if not user or not isinstance(user, dict): + return jsonify({"error": {"code": "MISSING_USER", "message": "user information is required"}}), 400 + + # Validate credit card information + credit_card = request_data.get("creditCard") + if not credit_card or not isinstance(credit_card, dict): + return jsonify({"error": {"code": "MISSING_CREDIT_CARD", "message": "creditCard information is required"}}), 400 - print("Request items:", items) + log.info(f"Request validation passed - {len(items)} items") # Prepare shared storage for thread results results = {} @@ -100,31 +133,40 @@ def worker_fraud_detection(): log.info("Thread: Starting fraud detection") try: fraud_detected, fraud_reason = call_fraud_detection(request_data) - results['fraud'] = {'detected': fraud_detected, 'reason': fraud_reason} + results['fraud'] = {'detected': fraud_detected, 'reason': fraud_reason, 'error': None} log.info(f"Thread: Fraud detection completed - detected={fraud_detected}") + except grpc.RpcError as e: + log.error(f"Thread: Fraud detection gRPC error - {e.code()}: {e.details()}") + results['fraud'] = {'detected': True, 'reason': 'Fraud detection service unavailable', 'error': 'SERVICE_UNAVAILABLE'} except Exception as e: - log.error(f"Thread: Fraud detection failed - {e}") - results['fraud'] = {'detected': True, 'reason': 'Service error'} + log.error(f"Thread: Fraud detection unexpected error - {e}") + results['fraud'] = {'detected': True, 'reason': 'Fraud detection service error', 'error': 'SERVICE_ERROR'} def worker_transaction_verification(): log.info("Thread: Starting transaction verification") try: is_valid, reason = call_transaction_verification(request_data) - results['transaction'] = {'valid': is_valid, 'reason': reason} + results['transaction'] = {'valid': is_valid, 'reason': reason, 'error': None} log.info(f"Thread: Transaction verification completed - valid={is_valid}") + except grpc.RpcError as e: + log.error(f"Thread: Transaction verification gRPC error - {e.code()}: {e.details()}") + results['transaction'] = {'valid': False, 'reason': 'Transaction verification service unavailable', 'error': 'SERVICE_UNAVAILABLE'} except Exception as e: - log.error(f"Thread: Transaction verification failed - {e}") - results['transaction'] = {'valid': False, 'reason': 'Service error'} + log.error(f"Thread: Transaction verification unexpected error - {e}") + results['transaction'] = {'valid': False, 'reason': 'Transaction verification service error', 'error': 'SERVICE_ERROR'} def worker_suggestions(): log.info("Thread: Starting suggestions") try: books = call_suggestions(request_data) - results['suggestions'] = {'books': books} + results['suggestions'] = {'books': books, 'error': None} log.info(f"Thread: Suggestions completed - {len(books)} books") + except grpc.RpcError as e: + log.error(f"Thread: Suggestions gRPC error - {e.code()}: {e.details()}") + results['suggestions'] = {'books': [], 'error': 'SERVICE_UNAVAILABLE'} except Exception as e: - log.error(f"Thread: Suggestions failed - {e}") - results['suggestions'] = {'books': []} + log.error(f"Thread: Suggestions unexpected error - {e}") + results['suggestions'] = {'books': [], 'error': 'SERVICE_ERROR'} # Create worker threads thread_fraud = threading.Thread(target=worker_fraud_detection, name="FraudThread") @@ -142,20 +184,45 @@ def worker_suggestions(): log.info("All threads completed") # Extract results from shared dict - fraud_detected = results.get('fraud', {}).get('detected', True) - fraud_reason = results.get('fraud', {}).get('reason', 'Unknown') - transaction_valid = results.get('transaction', {}).get('valid', False) - transaction_reason = results.get('transaction', {}).get('reason', 'Unknown') - suggested_books = results.get('suggestions', {}).get('books', []) + fraud_data = results.get('fraud', {}) + fraud_detected = fraud_data.get('detected', True) + fraud_reason = fraud_data.get('reason', 'Unknown') + fraud_error = fraud_data.get('error') + + transaction_data = results.get('transaction', {}) + transaction_valid = transaction_data.get('valid', False) + transaction_reason = transaction_data.get('reason', 'Unknown') + transaction_error = transaction_data.get('error') + + suggestions_data = results.get('suggestions', {}) + suggested_books = suggestions_data.get('books', []) + suggestions_error = suggestions_data.get('error') - print("Fraud result:", fraud_detected, fraud_reason) - print("Transaction result:", transaction_valid, transaction_reason) - print("Suggestions result:", len(suggested_books), "books") + log.info(f"Results - Fraud: {fraud_detected} ({fraud_reason}), Transaction: {transaction_valid} ({transaction_reason}), Suggestions: {len(suggested_books)} books") + + # Check if any critical service failed + if fraud_error or transaction_error: + error_details = [] + if fraud_error: + error_details.append("fraud_detection") + if transaction_error: + error_details.append("transaction_verification") + + log.error(f"Critical services unavailable: {', '.join(error_details)}") + return jsonify({ + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": f"One or more backend services are unavailable: {', '.join(error_details)}" + } + }), 503 # Consolidate results: reject if fraud detected OR transaction invalid approved = (not fraud_detected) and transaction_valid if not approved: suggested_books = [] + log.info(f"Order rejected - Fraud: {fraud_detected}, Valid: {transaction_valid}") + else: + log.info("Order approved") order_status_response = { "orderId": "12345", From de75d2056c120c33bc0814e7cd2ce97d5b5cb118 Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 15:29:35 +0200 Subject: [PATCH 06/10] ref: move services to /services - cleanup before it is total mess --- docker-compose.yaml | 28 +++++++++---------- .../fraud_detection}/Dockerfile | 4 +-- .../fraud_detection}/requirements.txt | 0 .../fraud_detection}/src/app.py | 2 +- {frontend => services/frontend}/Dockerfile | 0 .../frontend}/src/index.html | 0 .../orchestrator}/Dockerfile | 4 +-- .../orchestrator}/requirements.txt | 0 .../orchestrator}/src/app.py | 2 +- .../suggestions}/Dockerfile | 4 +-- .../suggestions}/requirements.txt | 0 .../suggestions}/src/app.py | 2 +- .../transaction_verification}/Dockerfile | 4 +-- .../requirements.txt | 0 .../transaction_verification}/src/app.py | 2 +- 15 files changed, 26 insertions(+), 26 deletions(-) rename {suggestions => services/fraud_detection}/Dockerfile (76%) rename {fraud_detection => services/fraud_detection}/requirements.txt (100%) rename {fraud_detection => services/fraud_detection}/src/app.py (97%) rename {frontend => services/frontend}/Dockerfile (100%) rename {frontend => services/frontend}/src/index.html (100%) rename {orchestrator => services/orchestrator}/Dockerfile (77%) rename {orchestrator => services/orchestrator}/requirements.txt (100%) rename {orchestrator => services/orchestrator}/src/app.py (99%) rename {fraud_detection => services/suggestions}/Dockerfile (77%) rename {suggestions => services/suggestions}/requirements.txt (100%) rename {suggestions => services/suggestions}/src/app.py (97%) rename {transaction_verification => services/transaction_verification}/Dockerfile (73%) rename {transaction_verification => services/transaction_verification}/requirements.txt (100%) rename {transaction_verification => services/transaction_verification}/src/app.py (98%) diff --git a/docker-compose.yaml b/docker-compose.yaml index b431569d1..64e7c7746 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,56 +2,56 @@ services: frontend: build: context: ./ - dockerfile: ./frontend/Dockerfile + dockerfile: ./services/frontend/Dockerfile ports: - "8080:80" volumes: - - ./frontend/src:/usr/share/nginx/html + - ./services/frontend/src:/usr/share/nginx/html orchestrator: build: context: ./ - dockerfile: ./orchestrator/Dockerfile + dockerfile: ./services/orchestrator/Dockerfile ports: - 8081:5000 environment: - PYTHONUNBUFFERED=TRUE - - PYTHONFILE=/app/orchestrator/src/app.py + - PYTHONFILE=/app/services/orchestrator/src/app.py volumes: - ./utils:/app/utils - - ./orchestrator/src:/app/orchestrator/src + - ./services/orchestrator/src:/app/services/orchestrator/src fraud_detection: build: context: ./ - dockerfile: ./fraud_detection/Dockerfile + dockerfile: ./services/fraud_detection/Dockerfile ports: - 50051:50051 environment: - PYTHONUNBUFFERED=TRUE - - PYTHONFILE=/app/fraud_detection/src/app.py + - PYTHONFILE=/app/services/fraud_detection/src/app.py volumes: - ./utils:/app/utils - - ./fraud_detection/src:/app/fraud_detection/src + - ./services/fraud_detection/src:/app/services/fraud_detection/src transaction_verification: build: context: ./ - dockerfile: ./transaction_verification/Dockerfile + dockerfile: ./services/transaction_verification/Dockerfile ports: - 50052:50052 environment: - PYTHONUNBUFFERED=TRUE - - PYTHONFILE=/app/transaction_verification/src/app.py + - PYTHONFILE=/app/services/transaction_verification/src/app.py volumes: - ./utils:/app/utils - - ./transaction_verification/src:/app/transaction_verification/src + - ./services/transaction_verification/src:/app/services/transaction_verification/src suggestions: build: context: ./ - dockerfile: ./suggestions/Dockerfile + dockerfile: ./services/suggestions/Dockerfile ports: - 50053:50053 environment: - PYTHONUNBUFFERED=TRUE - - PYTHONFILE=/app/suggestions/src/app.py + - PYTHONFILE=/app/services/suggestions/src/app.py volumes: - ./utils:/app/utils - - ./suggestions/src:/app/suggestions/src + - ./services/suggestions/src:/app/services/suggestions/src diff --git a/suggestions/Dockerfile b/services/fraud_detection/Dockerfile similarity index 76% rename from suggestions/Dockerfile rename to services/fraud_detection/Dockerfile index c37e98faf..a47859604 100644 --- a/suggestions/Dockerfile +++ b/services/fraud_detection/Dockerfile @@ -6,10 +6,10 @@ FROM python:3.11 WORKDIR /app # Copy the requirements file to the working directory -COPY ./suggestions/requirements.txt . +COPY ./services/fraud_detection/requirements.txt . # Install the Python dependencies RUN pip install --no-cache-dir -r requirements.txt # Set the command to run the application -CMD python utils/other/hotreload.py "suggestions/src/app.py" +CMD python utils/other/hotreload.py "services/fraud_detection/src/app.py" \ No newline at end of file diff --git a/fraud_detection/requirements.txt b/services/fraud_detection/requirements.txt similarity index 100% rename from fraud_detection/requirements.txt rename to services/fraud_detection/requirements.txt diff --git a/fraud_detection/src/app.py b/services/fraud_detection/src/app.py similarity index 97% rename from fraud_detection/src/app.py rename to services/fraud_detection/src/app.py index a71a782e6..904482278 100644 --- a/fraud_detection/src/app.py +++ b/services/fraud_detection/src/app.py @@ -8,7 +8,7 @@ # The path of the stubs is relative to the current file, or absolute inside the container. # Change these lines only if strictly needed. FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") -pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) sys.path.insert(0, pb_root) from fraud_detection import fraud_detection_pb2 as fd_pb2 diff --git a/frontend/Dockerfile b/services/frontend/Dockerfile similarity index 100% rename from frontend/Dockerfile rename to services/frontend/Dockerfile diff --git a/frontend/src/index.html b/services/frontend/src/index.html similarity index 100% rename from frontend/src/index.html rename to services/frontend/src/index.html diff --git a/orchestrator/Dockerfile b/services/orchestrator/Dockerfile similarity index 77% rename from orchestrator/Dockerfile rename to services/orchestrator/Dockerfile index 5e898ba83..59200436a 100644 --- a/orchestrator/Dockerfile +++ b/services/orchestrator/Dockerfile @@ -6,10 +6,10 @@ FROM python:3.11 WORKDIR /app # Copy the requirements file to the working directory -COPY ./orchestrator/requirements.txt . +COPY ./services/orchestrator/requirements.txt . # Install the Python dependencies RUN pip install --no-cache-dir -r requirements.txt # Set the command to run the container -CMD python utils/other/hotreload.py "orchestrator/src/app.py" +CMD python utils/other/hotreload.py "services/orchestrator/src/app.py" diff --git a/orchestrator/requirements.txt b/services/orchestrator/requirements.txt similarity index 100% rename from orchestrator/requirements.txt rename to services/orchestrator/requirements.txt diff --git a/orchestrator/src/app.py b/services/orchestrator/src/app.py similarity index 99% rename from orchestrator/src/app.py rename to services/orchestrator/src/app.py index 9dcc5197b..6c26b5479 100644 --- a/orchestrator/src/app.py +++ b/services/orchestrator/src/app.py @@ -13,7 +13,7 @@ # Import gRPC generated stubs FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") -pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) sys.path.insert(0, pb_root) from fraud_detection import fraud_detection_pb2 as fd_pb2 diff --git a/fraud_detection/Dockerfile b/services/suggestions/Dockerfile similarity index 77% rename from fraud_detection/Dockerfile rename to services/suggestions/Dockerfile index 341df7f6f..61cb84f6a 100644 --- a/fraud_detection/Dockerfile +++ b/services/suggestions/Dockerfile @@ -6,10 +6,10 @@ FROM python:3.11 WORKDIR /app # Copy the requirements file to the working directory -COPY ./fraud_detection/requirements.txt . +COPY ./services/suggestions/requirements.txt . # Install the Python dependencies RUN pip install --no-cache-dir -r requirements.txt # Set the command to run the application -CMD python utils/other/hotreload.py "fraud_detection/src/app.py" \ No newline at end of file +CMD python utils/other/hotreload.py "services/suggestions/src/app.py" diff --git a/suggestions/requirements.txt b/services/suggestions/requirements.txt similarity index 100% rename from suggestions/requirements.txt rename to services/suggestions/requirements.txt diff --git a/suggestions/src/app.py b/services/suggestions/src/app.py similarity index 97% rename from suggestions/src/app.py rename to services/suggestions/src/app.py index b1a638f9f..4a7e7a208 100644 --- a/suggestions/src/app.py +++ b/services/suggestions/src/app.py @@ -8,7 +8,7 @@ # The path of the stubs is relative to the current file, or absolute inside the container. # Change these lines only if strictly needed. FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") -pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) sys.path.insert(0, pb_root) from suggestions import suggestions_pb2 as sg_pb2 diff --git a/transaction_verification/Dockerfile b/services/transaction_verification/Dockerfile similarity index 73% rename from transaction_verification/Dockerfile rename to services/transaction_verification/Dockerfile index 497987e02..9fdfebc8d 100644 --- a/transaction_verification/Dockerfile +++ b/services/transaction_verification/Dockerfile @@ -6,10 +6,10 @@ FROM python:3.11 WORKDIR /app # Copy the requirements file to the working directory -COPY ./transaction_verification/requirements.txt . +COPY ./services/transaction_verification/requirements.txt . # Install the Python dependencies RUN pip install --no-cache-dir -r requirements.txt # Set the command to run the application -CMD python utils/other/hotreload.py "transaction_verification/src/app.py" +CMD python utils/other/hotreload.py "services/transaction_verification/src/app.py" diff --git a/transaction_verification/requirements.txt b/services/transaction_verification/requirements.txt similarity index 100% rename from transaction_verification/requirements.txt rename to services/transaction_verification/requirements.txt diff --git a/transaction_verification/src/app.py b/services/transaction_verification/src/app.py similarity index 98% rename from transaction_verification/src/app.py rename to services/transaction_verification/src/app.py index f983f4ce7..5b30b0e5e 100644 --- a/transaction_verification/src/app.py +++ b/services/transaction_verification/src/app.py @@ -8,7 +8,7 @@ # The path of the stubs is relative to the current file, or absolute inside the container. # Change these lines only if strictly needed. FILE = __file__ if '__file__' in globals() else os.getenv("PYTHONFILE", "") -pb_root = os.path.abspath(os.path.join(FILE, "../../../utils/pb")) +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) sys.path.insert(0, pb_root) from transaction_verification import transaction_verification_pb2 as tv_pb2 From 378b1a2bfef0e94b8ec133f9e6b9cc8f45973731 Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 15:46:15 +0200 Subject: [PATCH 07/10] =?UTF-8?q?test=20:=20=E2=80=9EI=20don=E2=80=99t=20w?= =?UTF-8?q?rite=20tests=20because=20I=E2=80=99m=20a=20perfectionist.=20I?= =?UTF-8?q?=20write=20tests=20because=20I=E2=80=99m=20scared.=E2=80=9C=20-?= =?UTF-8?q?=20tests=20were=20generated=20by=20copilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests.yml | 279 +++++++++++++ .gitignore | 5 +- docker-compose.yaml | 4 + services/fraud_detection/requirements.txt | 3 + services/fraud_detection/tests/__init__.py | 1 + .../tests/test_fraud_detection.py | 179 ++++++++ services/orchestrator/requirements.txt | 5 +- services/orchestrator/tests/__init__.py | 1 + .../orchestrator/tests/test_orchestrator.py | 395 ++++++++++++++++++ services/suggestions/requirements.txt | 3 + services/suggestions/tests/__init__.py | 1 + .../suggestions/tests/test_suggestions.py | 162 +++++++ .../transaction_verification/requirements.txt | 3 + .../tests/__init__.py | 1 + .../tests/test_transaction_verification.py | 284 +++++++++++++ 15 files changed, 1324 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 services/fraud_detection/tests/__init__.py create mode 100644 services/fraud_detection/tests/test_fraud_detection.py create mode 100644 services/orchestrator/tests/__init__.py create mode 100644 services/orchestrator/tests/test_orchestrator.py create mode 100644 services/suggestions/tests/__init__.py create mode 100644 services/suggestions/tests/test_suggestions.py create mode 100644 services/transaction_verification/tests/__init__.py create mode 100644 services/transaction_verification/tests/test_transaction_verification.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..b5ba2a9de --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,279 @@ +name: Test All Services + +on: + push: + branches: [ master, feature/** ] + pull_request: + branches: [ master ] + +jobs: + test-fraud-detection: + name: Test Fraud Detection Service + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: ./services/fraud_detection + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Generate gRPC stubs + working-directory: ./utils/pb/fraud_detection + run: | + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. fraud_detection.proto + + - name: Run tests with pytest + working-directory: ./services/fraud_detection + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v4 + continue-on-error: true + with: + files: ./services/fraud_detection/coverage.xml + flags: fraud-detection + name: fraud-detection-coverage + + test-transaction-verification: + name: Test Transaction Verification Service + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: ./services/transaction_verification + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Generate gRPC stubs + working-directory: ./utils/pb/transaction_verification + run: | + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. transaction_verification.proto + + - name: Run tests with pytest + working-directory: ./services/transaction_verification + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v4 + continue-on-error: true + with: + files: ./services/transaction_verification/coverage.xml + flags: transaction-verification + name: transaction-verification-coverage + + test-suggestions: + name: Test Suggestions Service + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: ./services/suggestions + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Generate gRPC stubs + working-directory: ./utils/pb/suggestions + run: | + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. suggestions.proto + + - name: Run tests with pytest + working-directory: ./services/suggestions + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v4 + continue-on-error: true + with: + files: ./services/suggestions/coverage.xml + flags: suggestions + name: suggestions-coverage + + test-orchestrator: + name: Test Orchestrator Service + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: ./services/orchestrator + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Generate all gRPC stubs + run: | + python -m grpc_tools.protoc -I./utils/pb/fraud_detection --python_out=./utils/pb/fraud_detection --grpc_python_out=./utils/pb/fraud_detection ./utils/pb/fraud_detection/fraud_detection.proto + python -m grpc_tools.protoc -I./utils/pb/transaction_verification --python_out=./utils/pb/transaction_verification --grpc_python_out=./utils/pb/transaction_verification ./utils/pb/transaction_verification/transaction_verification.proto + python -m grpc_tools.protoc -I./utils/pb/suggestions --python_out=./utils/pb/suggestions --grpc_python_out=./utils/pb/suggestions ./utils/pb/suggestions/suggestions.proto + + - name: Run tests with pytest + working-directory: ./services/orchestrator + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v4 + continue-on-error: true + with: + files: ./services/orchestrator/coverage.xml + flags: orchestrator + name: orchestrator-coverage + + integration-test: + name: Integration Test with Docker Compose + runs-on: ubuntu-latest + needs: [test-fraud-detection, test-transaction-verification, test-suggestions, test-orchestrator] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build and start services + run: | + docker compose up -d --build + + - name: Wait for services to be ready + run: | + echo "Waiting for services to start..." + sleep 15 + + - name: Check services are running + run: | + docker compose ps + docker compose logs + + - name: Test orchestrator health endpoint + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/) + if [ "$response" != "200" ]; then + echo "Health check failed with status code: $response" + exit 1 + fi + echo "Health check passed" + + - name: Test valid checkout request + run: | + response=$(curl -s -X POST http://localhost:8081/checkout \ + -H "Content-Type: application/json" \ + -d '{ + "user": {"name": "Test User", "contact": "test@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Test Book", "quantity": 2}] + }') + echo "Response: $response" + + status=$(echo $response | jq -r '.status') + if [ "$status" != "Order Approved" ]; then + echo "Expected 'Order Approved', got: $status" + exit 1 + fi + echo "Valid checkout test passed" + + - name: Test fraud detection (high quantity) + run: | + response=$(curl -s -X POST http://localhost:8081/checkout \ + -H "Content-Type: application/json" \ + -d '{ + "user": {"name": "Test User", "contact": "test@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Test Book", "quantity": 60}] + }') + echo "Response: $response" + + status=$(echo $response | jq -r '.status') + if [ "$status" != "Order Rejected" ]; then + echo "Expected 'Order Rejected', got: $status" + exit 1 + fi + echo "Fraud detection test passed" + + - name: Test validation (missing user) + run: | + response=$(curl -s -X POST http://localhost:8081/checkout \ + -H "Content-Type: application/json" \ + -d '{ + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Test Book", "quantity": 2}] + }') + echo "Response: $response" + + error_code=$(echo $response | jq -r '.error.code') + if [ "$error_code" != "MISSING_USER" ]; then + echo "Expected error code 'MISSING_USER', got: $error_code" + exit 1 + fi + echo "Validation test passed" + + - name: Show service logs on failure + if: failure() + run: | + echo "=== Orchestrator logs ===" + docker compose logs orchestrator + echo "=== Fraud Detection logs ===" + docker compose logs fraud_detection + echo "=== Transaction Verification logs ===" + docker compose logs transaction_verification + echo "=== Suggestions logs ===" + docker compose logs suggestions + + - name: Stop services + if: always() + run: | + docker compose down -v + + test-summary: + name: Test Summary + runs-on: ubuntu-latest + needs: [integration-test] + if: always() + + steps: + - name: Check test results + run: | + if [ "${{ needs.test-fraud-detection.result }}" != "success" ] || \ + [ "${{ needs.test-transaction-verification.result }}" != "success" ] || \ + [ "${{ needs.test-suggestions.result }}" != "success" ] || \ + [ "${{ needs.test-orchestrator.result }}" != "success" ] || \ + [ "${{ needs.integration-test.result }}" != "success" ]; then + echo "❌ Some tests failed!" + exit 1 + else + echo "✅ All tests passed!" + fi diff --git a/.gitignore b/.gitignore index ed8ebf583..729f15286 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -__pycache__ \ No newline at end of file +__pycache__ +.pytest_cache +.vscode/ +vscode/ \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 64e7c7746..c002f4ab5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,6 +19,7 @@ services: volumes: - ./utils:/app/utils - ./services/orchestrator/src:/app/services/orchestrator/src + - ./services/orchestrator/tests:/app/services/orchestrator/tests fraud_detection: build: context: ./ @@ -31,6 +32,7 @@ services: volumes: - ./utils:/app/utils - ./services/fraud_detection/src:/app/services/fraud_detection/src + - ./services/fraud_detection/tests:/app/services/fraud_detection/tests transaction_verification: build: context: ./ @@ -43,6 +45,7 @@ services: volumes: - ./utils:/app/utils - ./services/transaction_verification/src:/app/services/transaction_verification/src + - ./services/transaction_verification/tests:/app/services/transaction_verification/tests suggestions: build: context: ./ @@ -55,3 +58,4 @@ services: volumes: - ./utils:/app/utils - ./services/suggestions/src:/app/services/suggestions/src + - ./services/suggestions/tests:/app/services/suggestions/tests diff --git a/services/fraud_detection/requirements.txt b/services/fraud_detection/requirements.txt index a806c0fd2..65ea12fd3 100644 --- a/services/fraud_detection/requirements.txt +++ b/services/fraud_detection/requirements.txt @@ -2,3 +2,6 @@ grpcio==1.78.0 grpcio-tools==1.78.0 protobuf>=5.26.1 watchdog==6.0.0 +pytest==8.0.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 diff --git a/services/fraud_detection/tests/__init__.py b/services/fraud_detection/tests/__init__.py new file mode 100644 index 000000000..bc6d51422 --- /dev/null +++ b/services/fraud_detection/tests/__init__.py @@ -0,0 +1 @@ +# Fraud detection tests package diff --git a/services/fraud_detection/tests/test_fraud_detection.py b/services/fraud_detection/tests/test_fraud_detection.py new file mode 100644 index 000000000..de7a50c8e --- /dev/null +++ b/services/fraud_detection/tests/test_fraud_detection.py @@ -0,0 +1,179 @@ +import sys +import os +import json +import pytest + +# Setup path to import the service +FILE = __file__ +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) +sys.path.insert(0, pb_root) + +from fraud_detection import fraud_detection_pb2 as fd_pb2 +from fraud_detection import fraud_detection_pb2_grpc as fd_grpc + +# Import the service implementation +sys.path.insert(0, os.path.abspath(os.path.join(FILE, "../../src"))) +from app import FraudDetectionService + + +class TestFraudDetectionService: + """Test suite for FraudDetectionService""" + + def setup_method(self): + """Setup test fixtures""" + self.service = FraudDetectionService() + self.context = None # Mock context (not needed for these tests) + + def test_valid_order_no_fraud(self): + """Test that a valid order passes fraud checks""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == False + assert response.reason == "OK" + + def test_fraud_too_many_items(self): + """Test fraud detection for orders with quantity >= 50""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 50}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Too many items" + + def test_fraud_missing_user_name(self): + """Test fraud detection for missing user name""" + order = { + "user": {"contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Missing user info" + + def test_fraud_missing_user_contact(self): + """Test fraud detection for missing user contact""" + order = { + "user": {"name": "John Doe"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Missing user info" + + def test_fraud_suspicious_card_number(self): + """Test fraud detection for invalid card number format""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "1234-5678-9012-3456", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Suspicious card number" + + def test_invalid_json(self): + """Test handling of invalid JSON""" + request = fd_pb2.FraudRequest(order_json="invalid json{{{") + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Invalid JSON" + + def test_multiple_items_total_quantity(self): + """Test that quantities are summed across multiple items""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [ + {"name": "Book A", "quantity": 25}, + {"name": "Book B", "quantity": 25} + ] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Too many items" + + def test_edge_case_quantity_49(self): + """Test edge case: quantity 49 should pass""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 49}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == False + assert response.reason == "OK" + + def test_empty_items_list(self): + """Test handling of empty items list""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == False + assert response.reason == "OK" + + def test_null_user(self): + """Test handling of null user object""" + order = { + "user": None, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == True + assert response.reason == "Missing user info" + + def test_valid_card_13_digits(self): + """Test valid card with 13 digits (minimum)""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "1234567890123", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == False + assert response.reason == "OK" + + def test_valid_card_19_digits(self): + """Test valid card with 19 digits (maximum)""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "1234567890123456789", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + response = self.service.CheckFraud(request, self.context) + + assert response.fraud_detected == False + assert response.reason == "OK" diff --git a/services/orchestrator/requirements.txt b/services/orchestrator/requirements.txt index 99265eea2..28d2f5415 100644 --- a/services/orchestrator/requirements.txt +++ b/services/orchestrator/requirements.txt @@ -9,4 +9,7 @@ MarkupSafe==2.1.3 protobuf>=5.26.1 Werkzeug==3.0.1 Flask-CORS==4.0.0 -watchdog==6.0.0 \ No newline at end of file +watchdog==6.0.0 +pytest==8.0.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 \ No newline at end of file diff --git a/services/orchestrator/tests/__init__.py b/services/orchestrator/tests/__init__.py new file mode 100644 index 000000000..e07e9810d --- /dev/null +++ b/services/orchestrator/tests/__init__.py @@ -0,0 +1 @@ +# Orchestrator tests package diff --git a/services/orchestrator/tests/test_orchestrator.py b/services/orchestrator/tests/test_orchestrator.py new file mode 100644 index 000000000..d8d88fc93 --- /dev/null +++ b/services/orchestrator/tests/test_orchestrator.py @@ -0,0 +1,395 @@ +import sys +import os +import json +import pytest +from unittest.mock import Mock, patch, MagicMock +import grpc + + +# Create a mock RpcError class with code() and details() methods +class MockRpcError(grpc.RpcError): + def __init__(self, code=grpc.StatusCode.UNAVAILABLE, details="Service unavailable"): + self._code = code + self._details = details + + def code(self): + return self._code + + def details(self): + return self._details + + +# Setup path to import the service +FILE = __file__ +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) +sys.path.insert(0, pb_root) + +# Import the orchestrator app +sys.path.insert(0, os.path.abspath(os.path.join(FILE, "../../src"))) +from app import app, mask_sensitive_data + + +@pytest.fixture +def client(): + """Create Flask test client""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +@pytest.fixture +def valid_order(): + """Sample valid order for testing""" + return { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + + +class TestHealthCheck: + """Test suite for health check endpoint""" + + def test_index_endpoint(self, client): + """Test that index endpoint returns 200""" + response = client.get('/') + assert response.status_code == 200 + assert b"Orchestrator is running" in response.data + + +class TestMaskSensitiveData: + """Test suite for data masking utility""" + + def test_mask_credit_card_number(self): + """Test that credit card number is masked""" + data = { + "creditCard": { + "number": "4532015112830366", + "cvv": "123" + } + } + masked = mask_sensitive_data(data) + assert masked['creditCard']['number'] == '****0366' + assert masked['creditCard']['cvv'] == '***' + + def test_mask_short_card_number(self): + """Test masking of short card numbers""" + data = { + "creditCard": { + "number": "123", + "cvv": "456" + } + } + masked = mask_sensitive_data(data) + assert masked['creditCard']['number'] == '****' + assert masked['creditCard']['cvv'] == '***' + + def test_mask_preserves_other_fields(self): + """Test that other fields are not modified""" + data = { + "user": {"name": "John", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + masked = mask_sensitive_data(data) + assert masked['user'] == data['user'] + assert masked['items'] == data['items'] + + def test_mask_handles_missing_credit_card(self): + """Test masking when credit card is missing""" + data = {"user": {"name": "John"}} + masked = mask_sensitive_data(data) + assert masked == data + + def test_mask_handles_non_dict(self): + """Test masking returns non-dict as-is""" + assert mask_sensitive_data("string") == "string" + assert mask_sensitive_data(123) == 123 + assert mask_sensitive_data(None) == None + + +class TestCheckoutValidation: + """Test suite for checkout request validation""" + + def test_missing_json_body(self, client): + """Test rejection of request without JSON body""" + response = client.post('/checkout', data='not json', content_type='text/plain') + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'INVALID_JSON' + + def test_empty_items_list(self, client, valid_order): + """Test rejection when items list is empty""" + valid_order['items'] = [] + response = client.post('/checkout', json=valid_order) + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'INVALID_ITEMS' + + def test_missing_items(self, client, valid_order): + """Test rejection when items field is missing""" + del valid_order['items'] + response = client.post('/checkout', json=valid_order) + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'INVALID_ITEMS' + + def test_missing_user(self, client, valid_order): + """Test rejection when user field is missing""" + del valid_order['user'] + response = client.post('/checkout', json=valid_order) + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'MISSING_USER' + + def test_null_user(self, client, valid_order): + """Test rejection when user is null""" + valid_order['user'] = None + response = client.post('/checkout', json=valid_order) + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'MISSING_USER' + + def test_missing_credit_card(self, client, valid_order): + """Test rejection when creditCard field is missing""" + del valid_order['creditCard'] + response = client.post('/checkout', json=valid_order) + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'MISSING_CREDIT_CARD' + + def test_null_credit_card(self, client, valid_order): + """Test rejection when creditCard is null""" + valid_order['creditCard'] = None + response = client.post('/checkout', json=valid_order) + assert response.status_code == 400 + data = json.loads(response.data) + assert data['error']['code'] == 'MISSING_CREDIT_CARD' + + +class TestCheckoutBusinessLogic: + """Test suite for checkout business logic with mocked services""" + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_approved_order(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test successful order approval when all checks pass""" + # Mock service responses + mock_fraud.return_value = (False, "OK") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [ + {"bookId": "101", "title": "Book 1", "author": "Author 1"}, + {"bookId": "102", "title": "Book 2", "author": "Author 2"} + ] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Approved' + assert data['orderId'] == '12345' + assert len(data['suggestedBooks']) == 2 + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_rejected_by_fraud(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test order rejection due to fraud detection""" + mock_fraud.return_value = (True, "Too many items") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Rejected' + assert data['suggestedBooks'] == [] + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_rejected_by_transaction(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test order rejection due to invalid transaction""" + mock_fraud.return_value = (False, "OK") + mock_transaction.return_value = (False, "Invalid credit card format") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Rejected' + assert data['suggestedBooks'] == [] + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_rejected_by_both(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test order rejection when both fraud and transaction fail""" + mock_fraud.return_value = (True, "Suspicious card") + mock_transaction.return_value = (False, "Missing user info") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Rejected' + assert data['suggestedBooks'] == [] + + +class TestServiceUnavailability: + """Test suite for handling service failures""" + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_fraud_service_unavailable(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test 503 response when fraud detection service is unavailable""" + mock_fraud.side_effect = MockRpcError() + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 503 + + data = json.loads(response.data) + assert data['error']['code'] == 'SERVICE_UNAVAILABLE' + assert 'fraud_detection' in data['error']['message'] + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_transaction_service_unavailable(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test 503 response when transaction verification service is unavailable""" + mock_fraud.return_value = (False, "OK") + mock_transaction.side_effect = MockRpcError() + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 503 + + data = json.loads(response.data) + assert data['error']['code'] == 'SERVICE_UNAVAILABLE' + assert 'transaction_verification' in data['error']['message'] + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_both_critical_services_unavailable(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test 503 response when both critical services are unavailable""" + mock_fraud.side_effect = MockRpcError() + mock_transaction.side_effect = MockRpcError() + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 503 + + data = json.loads(response.data) + assert data['error']['code'] == 'SERVICE_UNAVAILABLE' + assert 'fraud_detection' in data['error']['message'] + assert 'transaction_verification' in data['error']['message'] + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_suggestions_service_unavailable_not_critical(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test that suggestions service failure doesn't cause 503""" + mock_fraud.return_value = (False, "OK") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.side_effect = MockRpcError() + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Approved' + assert data['suggestedBooks'] == [] + + +class TestThreadSafety: + """Test suite for multithreading functionality""" + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_all_services_called(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test that all three services are called""" + mock_fraud.return_value = (False, "OK") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [{"bookId": "101", "title": "Book", "author": "Author"}] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + # Verify all services were called + mock_fraud.assert_called_once() + mock_transaction.assert_called_once() + mock_suggestions.assert_called_once() + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_services_receive_correct_order_data(self, mock_suggestions, mock_transaction, mock_fraud, client, valid_order): + """Test that services receive the correct order data""" + mock_fraud.return_value = (False, "OK") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=valid_order) + assert response.status_code == 200 + + # Verify each service received the order data + mock_fraud.assert_called_with(valid_order) + mock_transaction.assert_called_with(valid_order) + mock_suggestions.assert_called_with(valid_order) + + +class TestEdgeCases: + """Test suite for edge cases""" + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_multiple_items(self, mock_suggestions, mock_transaction, mock_fraud, client): + """Test order with multiple items""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [ + {"name": "Book A", "quantity": 2}, + {"name": "Book B", "quantity": 3}, + {"name": "Book C", "quantity": 1} + ] + } + mock_fraud.return_value = (False, "OK") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Approved' + + @patch('app.call_fraud_detection') + @patch('app.call_transaction_verification') + @patch('app.call_suggestions') + def test_large_quantity(self, mock_suggestions, mock_transaction, mock_fraud, client): + """Test order with large quantity""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 1000}] + } + mock_fraud.return_value = (True, "Too many items") + mock_transaction.return_value = (True, "Transaction valid") + mock_suggestions.return_value = [] + + response = client.post('/checkout', json=order) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['status'] == 'Order Rejected' diff --git a/services/suggestions/requirements.txt b/services/suggestions/requirements.txt index a806c0fd2..65ea12fd3 100644 --- a/services/suggestions/requirements.txt +++ b/services/suggestions/requirements.txt @@ -2,3 +2,6 @@ grpcio==1.78.0 grpcio-tools==1.78.0 protobuf>=5.26.1 watchdog==6.0.0 +pytest==8.0.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 diff --git a/services/suggestions/tests/__init__.py b/services/suggestions/tests/__init__.py new file mode 100644 index 000000000..35afce046 --- /dev/null +++ b/services/suggestions/tests/__init__.py @@ -0,0 +1 @@ +# Suggestions tests package diff --git a/services/suggestions/tests/test_suggestions.py b/services/suggestions/tests/test_suggestions.py new file mode 100644 index 000000000..208523793 --- /dev/null +++ b/services/suggestions/tests/test_suggestions.py @@ -0,0 +1,162 @@ +import sys +import os +import json +import pytest + +# Setup path to import the service +FILE = __file__ +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) +sys.path.insert(0, pb_root) + +from suggestions import suggestions_pb2 as sg_pb2 +from suggestions import suggestions_pb2_grpc as sg_grpc + +# Import the service implementation +sys.path.insert(0, os.path.abspath(os.path.join(FILE, "../../src"))) +from app import SuggestionsService, BOOK_CATALOG + + +class TestSuggestionsService: + """Test suite for SuggestionsService""" + + def setup_method(self): + """Setup test fixtures""" + self.service = SuggestionsService() + self.context = None + + def test_valid_request_returns_three_books(self): + """Test that valid request returns exactly 3 book suggestions""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + assert len(response.books) == 3 + + def test_returned_books_have_required_fields(self): + """Test that returned books have all required fields""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + for book in response.books: + assert book.book_id != "" + assert book.title != "" + assert book.author != "" + + def test_returned_books_are_from_catalog(self): + """Test that all returned books are from the BOOK_CATALOG""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + catalog_ids = {book["book_id"] for book in BOOK_CATALOG} + for book in response.books: + assert book.book_id in catalog_ids + + def test_returned_books_are_unique(self): + """Test that returned books don't contain duplicates""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + book_ids = [book.book_id for book in response.books] + assert len(book_ids) == len(set(book_ids)), "Duplicate books returned" + + def test_invalid_json_returns_empty_list(self): + """Test that invalid JSON returns empty suggestions list""" + request = sg_pb2.SuggestionsRequest(order_json="invalid json{{{") + response = self.service.GetSuggestions(request, self.context) + + assert len(response.books) == 0 + + def test_empty_order_still_returns_suggestions(self): + """Test that even empty order returns suggestions""" + order = {} + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + assert len(response.books) == 3 + + def test_multiple_calls_may_return_different_books(self): + """Test that suggestions are randomized (multiple calls may differ)""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + + # Call 10 times and collect all book IDs + all_book_ids = set() + for _ in range(10): + response = self.service.GetSuggestions(request, self.context) + for book in response.books: + all_book_ids.add(book.book_id) + + # With randomization, we should see more than 3 different books across 10 calls + # (This test might occasionally fail due to randomness, but probability is very low) + assert len(all_book_ids) > 3, "Suggestions don't appear to be randomized" + + def test_book_catalog_has_expected_size(self): + """Test that BOOK_CATALOG contains expected number of books""" + assert len(BOOK_CATALOG) == 10 + + def test_book_catalog_integrity(self): + """Test that all books in catalog have required fields""" + for book in BOOK_CATALOG: + assert "book_id" in book + assert "title" in book + assert "author" in book + assert book["book_id"] != "" + assert book["title"] != "" + assert book["author"] != "" + + def test_suggestions_with_fraud_order(self): + """Test that suggestions work even for potentially fraudulent orders""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 100}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + # Suggestions should still work regardless of order validity + assert len(response.books) == 3 + + def test_suggestions_with_missing_fields(self): + """Test that suggestions work even when order has missing fields""" + order = { + "items": [{"name": "Book A"}] + } + request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) + response = self.service.GetSuggestions(request, self.context) + + assert len(response.books) == 3 + + def test_specific_book_ids_format(self): + """Test that book IDs are in expected format (101-110)""" + expected_ids = {str(i) for i in range(101, 111)} + catalog_ids = {book["book_id"] for book in BOOK_CATALOG} + + assert catalog_ids == expected_ids + + def test_known_books_in_catalog(self): + """Test that certain well-known books are in the catalog""" + catalog_titles = {book["title"] for book in BOOK_CATALOG} + + assert "1984" in catalog_titles + assert "The Great Gatsby" in catalog_titles + assert "The Hobbit" in catalog_titles diff --git a/services/transaction_verification/requirements.txt b/services/transaction_verification/requirements.txt index a806c0fd2..65ea12fd3 100644 --- a/services/transaction_verification/requirements.txt +++ b/services/transaction_verification/requirements.txt @@ -2,3 +2,6 @@ grpcio==1.78.0 grpcio-tools==1.78.0 protobuf>=5.26.1 watchdog==6.0.0 +pytest==8.0.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 diff --git a/services/transaction_verification/tests/__init__.py b/services/transaction_verification/tests/__init__.py new file mode 100644 index 000000000..3b596a128 --- /dev/null +++ b/services/transaction_verification/tests/__init__.py @@ -0,0 +1 @@ +# Transaction verification tests package diff --git a/services/transaction_verification/tests/test_transaction_verification.py b/services/transaction_verification/tests/test_transaction_verification.py new file mode 100644 index 000000000..01efbefb5 --- /dev/null +++ b/services/transaction_verification/tests/test_transaction_verification.py @@ -0,0 +1,284 @@ +import sys +import os +import json +import pytest + +# Setup path to import the service +FILE = __file__ +pb_root = os.path.abspath(os.path.join(FILE, "../../../../utils/pb")) +sys.path.insert(0, pb_root) + +from transaction_verification import transaction_verification_pb2 as tv_pb2 +from transaction_verification import transaction_verification_pb2_grpc as tv_grpc + +# Import the service implementation +sys.path.insert(0, os.path.abspath(os.path.join(FILE, "../../src"))) +from app import TransactionVerificationService + + +class TestTransactionVerificationService: + """Test suite for TransactionVerificationService""" + + def setup_method(self): + """Setup test fixtures""" + self.service = TransactionVerificationService() + self.context = None + + def test_valid_transaction(self): + """Test that a valid transaction passes all checks""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == True + assert response.reason == "Transaction valid" + + def test_invalid_json(self): + """Test handling of invalid JSON""" + request = tv_pb2.TransactionRequest(order_json="not valid json{{") + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid JSON" + + def test_empty_items_list(self): + """Test rejection when items list is empty""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "No items in order" + + def test_missing_user_name(self): + """Test rejection when user name is missing""" + order = { + "user": {"contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing user name" + + def test_missing_user_contact(self): + """Test rejection when user contact is missing""" + order = { + "user": {"name": "John Doe"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing user contact" + + def test_missing_credit_card_number(self): + """Test rejection when credit card number is missing""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing credit card number" + + def test_missing_expiration_date(self): + """Test rejection when expiration date is missing""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing expiration date" + + def test_missing_cvv(self): + """Test rejection when CVV is missing""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing CVV" + + def test_invalid_card_format_with_dashes(self): + """Test rejection of card number with dashes""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532-0151-1283-0366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid credit card format" + + def test_invalid_card_too_short(self): + """Test rejection of card number with less than 13 digits""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "123456789012", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid credit card format" + + def test_invalid_card_too_long(self): + """Test rejection of card number with more than 19 digits""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "12345678901234567890", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid credit card format" + + def test_invalid_cvv_too_short(self): + """Test rejection of CVV with less than 3 digits""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "12"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid CVV format" + + def test_invalid_cvv_too_long(self): + """Test rejection of CVV with more than 4 digits""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "12345"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid CVV format" + + def test_valid_cvv_4_digits(self): + """Test valid CVV with 4 digits (AMEX format)""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "1234"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == True + assert response.reason == "Transaction valid" + + def test_item_missing_name(self): + """Test rejection when item name is missing""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Item missing name" + + def test_item_zero_quantity(self): + """Test rejection when item quantity is zero""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 0}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid item quantity" + + def test_item_negative_quantity(self): + """Test rejection when item quantity is negative""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": -1}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Invalid item quantity" + + def test_multiple_valid_items(self): + """Test valid transaction with multiple items""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [ + {"name": "Book A", "quantity": 2}, + {"name": "Book B", "quantity": 3}, + {"name": "Book C", "quantity": 1} + ] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == True + assert response.reason == "Transaction valid" + + def test_null_user_object(self): + """Test rejection when user object is null""" + order = { + "user": None, + "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing user name" + + def test_null_credit_card_object(self): + """Test rejection when credit card object is null""" + order = { + "user": {"name": "John Doe", "contact": "john@example.com"}, + "creditCard": None, + "items": [{"name": "Book A", "quantity": 2}] + } + request = tv_pb2.TransactionRequest(order_json=json.dumps(order)) + response = self.service.VerifyTransaction(request, self.context) + + assert response.is_valid == False + assert response.reason == "Missing credit card number" From 3dff3dac383f60d4851c4641a8a171397b458f9a Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 15:51:10 +0200 Subject: [PATCH 08/10] fix: tests import errs --- .github/workflows/tests.yml | 6 +++++ .../tests/test_fraud_detection.py | 22 +++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b5ba2a9de..30af74423 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,7 @@ jobs: working-directory: ./utils/pb/fraud_detection run: | python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. fraud_detection.proto + sed -i 's/^import fraud_detection_pb2/from . import fraud_detection_pb2/' fraud_detection_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/fraud_detection @@ -67,6 +68,7 @@ jobs: working-directory: ./utils/pb/transaction_verification run: | python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. transaction_verification.proto + sed -i 's/^import transaction_verification_pb2/from . import transaction_verification_pb2/' transaction_verification_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/transaction_verification @@ -104,6 +106,7 @@ jobs: working-directory: ./utils/pb/suggestions run: | python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. suggestions.proto + sed -i 's/^import suggestions_pb2/from . import suggestions_pb2/' suggestions_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/suggestions @@ -142,6 +145,9 @@ jobs: python -m grpc_tools.protoc -I./utils/pb/fraud_detection --python_out=./utils/pb/fraud_detection --grpc_python_out=./utils/pb/fraud_detection ./utils/pb/fraud_detection/fraud_detection.proto python -m grpc_tools.protoc -I./utils/pb/transaction_verification --python_out=./utils/pb/transaction_verification --grpc_python_out=./utils/pb/transaction_verification ./utils/pb/transaction_verification/transaction_verification.proto python -m grpc_tools.protoc -I./utils/pb/suggestions --python_out=./utils/pb/suggestions --grpc_python_out=./utils/pb/suggestions ./utils/pb/suggestions/suggestions.proto + sed -i 's/^import fraud_detection_pb2/from . import fraud_detection_pb2/' ./utils/pb/fraud_detection/fraud_detection_pb2_grpc.py + sed -i 's/^import transaction_verification_pb2/from . import transaction_verification_pb2/' ./utils/pb/transaction_verification/transaction_verification_pb2_grpc.py + sed -i 's/^import suggestions_pb2/from . import suggestions_pb2/' ./utils/pb/suggestions/suggestions_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/orchestrator diff --git a/services/fraud_detection/tests/test_fraud_detection.py b/services/fraud_detection/tests/test_fraud_detection.py index de7a50c8e..3b63c5c7e 100644 --- a/services/fraud_detection/tests/test_fraud_detection.py +++ b/services/fraud_detection/tests/test_fraud_detection.py @@ -44,7 +44,7 @@ def test_fraud_too_many_items(self): "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 50}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -57,7 +57,7 @@ def test_fraud_missing_user_name(self): "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 2}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -70,7 +70,7 @@ def test_fraud_missing_user_contact(self): "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 2}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -83,7 +83,7 @@ def test_fraud_suspicious_card_number(self): "creditCard": {"number": "1234-5678-9012-3456", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 2}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -91,7 +91,7 @@ def test_fraud_suspicious_card_number(self): def test_invalid_json(self): """Test handling of invalid JSON""" - request = fd_pb2.FraudRequest(order_json="invalid json{{{") + request = fd_pb2.OrderRequest(order_json="invalid json{{{") response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -107,7 +107,7 @@ def test_multiple_items_total_quantity(self): {"name": "Book B", "quantity": 25} ] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -120,7 +120,7 @@ def test_edge_case_quantity_49(self): "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 49}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == False @@ -133,7 +133,7 @@ def test_empty_items_list(self): "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, "items": [] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == False @@ -146,7 +146,7 @@ def test_null_user(self): "creditCard": {"number": "4532015112830366", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 2}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == True @@ -159,7 +159,7 @@ def test_valid_card_13_digits(self): "creditCard": {"number": "1234567890123", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 2}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == False @@ -172,7 +172,7 @@ def test_valid_card_19_digits(self): "creditCard": {"number": "1234567890123456789", "expirationDate": "12/25", "cvv": "123"}, "items": [{"name": "Book A", "quantity": 2}] } - request = fd_pb2.FraudRequest(order_json=json.dumps(order)) + request = fd_pb2.OrderRequest(order_json=json.dumps(order)) response = self.service.CheckFraud(request, self.context) assert response.fraud_detected == False From 1f271408d520a8054c5069eb62ea6140229e3940 Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Sat, 21 Feb 2026 15:57:50 +0200 Subject: [PATCH 09/10] fix: test summary --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 30af74423..f37080589 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -267,7 +267,7 @@ jobs: test-summary: name: Test Summary runs-on: ubuntu-latest - needs: [integration-test] + needs: [test-fraud-detection, test-transaction-verification, test-suggestions, test-orchestrator, integration-test] if: always() steps: From ffc9073b9c5f427c1dbae628faf9f84441f26c75 Mon Sep 17 00:00:00 2001 From: Tomas Magula Date: Tue, 24 Feb 2026 16:05:35 +0200 Subject: [PATCH 10/10] fix: update gRPC stub generation paths and improve test for SuggestionsService randomness --- .github/workflows/tests.yml | 31 +++++++++-------- services/fraud_detection/src/app.py | 2 +- .../suggestions/tests/test_suggestions.py | 34 +++++++++++++------ 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f37080589..9d03564df 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,10 +27,10 @@ jobs: pip install -r requirements.txt - name: Generate gRPC stubs - working-directory: ./utils/pb/fraud_detection + working-directory: ./utils/pb run: | - python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. fraud_detection.proto - sed -i 's/^import fraud_detection_pb2/from . import fraud_detection_pb2/' fraud_detection_pb2_grpc.py + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. fraud_detection/fraud_detection.proto + sed -i 's/^import fraud_detection_pb2/from . import fraud_detection_pb2/' fraud_detection/fraud_detection_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/fraud_detection @@ -65,10 +65,10 @@ jobs: pip install -r requirements.txt - name: Generate gRPC stubs - working-directory: ./utils/pb/transaction_verification + working-directory: ./utils/pb run: | - python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. transaction_verification.proto - sed -i 's/^import transaction_verification_pb2/from . import transaction_verification_pb2/' transaction_verification_pb2_grpc.py + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. transaction_verification/transaction_verification.proto + sed -i 's/^import transaction_verification_pb2/from . import transaction_verification_pb2/' transaction_verification/transaction_verification_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/transaction_verification @@ -103,10 +103,10 @@ jobs: pip install -r requirements.txt - name: Generate gRPC stubs - working-directory: ./utils/pb/suggestions + working-directory: ./utils/pb run: | - python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. suggestions.proto - sed -i 's/^import suggestions_pb2/from . import suggestions_pb2/' suggestions_pb2_grpc.py + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. suggestions/suggestions.proto + sed -i 's/^import suggestions_pb2/from . import suggestions_pb2/' suggestions/suggestions_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/suggestions @@ -141,13 +141,14 @@ jobs: pip install -r requirements.txt - name: Generate all gRPC stubs + working-directory: ./utils/pb run: | - python -m grpc_tools.protoc -I./utils/pb/fraud_detection --python_out=./utils/pb/fraud_detection --grpc_python_out=./utils/pb/fraud_detection ./utils/pb/fraud_detection/fraud_detection.proto - python -m grpc_tools.protoc -I./utils/pb/transaction_verification --python_out=./utils/pb/transaction_verification --grpc_python_out=./utils/pb/transaction_verification ./utils/pb/transaction_verification/transaction_verification.proto - python -m grpc_tools.protoc -I./utils/pb/suggestions --python_out=./utils/pb/suggestions --grpc_python_out=./utils/pb/suggestions ./utils/pb/suggestions/suggestions.proto - sed -i 's/^import fraud_detection_pb2/from . import fraud_detection_pb2/' ./utils/pb/fraud_detection/fraud_detection_pb2_grpc.py - sed -i 's/^import transaction_verification_pb2/from . import transaction_verification_pb2/' ./utils/pb/transaction_verification/transaction_verification_pb2_grpc.py - sed -i 's/^import suggestions_pb2/from . import suggestions_pb2/' ./utils/pb/suggestions/suggestions_pb2_grpc.py + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. fraud_detection/fraud_detection.proto + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. transaction_verification/transaction_verification.proto + python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. suggestions/suggestions.proto + sed -i 's/^import fraud_detection_pb2/from . import fraud_detection_pb2/' fraud_detection/fraud_detection_pb2_grpc.py + sed -i 's/^import transaction_verification_pb2/from . import transaction_verification_pb2/' transaction_verification/transaction_verification_pb2_grpc.py + sed -i 's/^import suggestions_pb2/from . import suggestions_pb2/' suggestions/suggestions_pb2_grpc.py - name: Run tests with pytest working-directory: ./services/orchestrator diff --git a/services/fraud_detection/src/app.py b/services/fraud_detection/src/app.py index 904482278..89439d3bc 100644 --- a/services/fraud_detection/src/app.py +++ b/services/fraud_detection/src/app.py @@ -21,7 +21,7 @@ log = logging.getLogger("fraud_detection") # Create a class to define the server functions, derived from -# fraud_detection_pb2_grpc.HelloServiceServicer +# fraud_detection_pb2_grpc.FraudDetectionServiceServicer class FraudDetectionService(fd_grpc.FraudDetectionServiceServicer): def CheckFraud(self, request, context): log.info("CheckFraud called") diff --git a/services/suggestions/tests/test_suggestions.py b/services/suggestions/tests/test_suggestions.py index 208523793..b62590a83 100644 --- a/services/suggestions/tests/test_suggestions.py +++ b/services/suggestions/tests/test_suggestions.py @@ -2,6 +2,7 @@ import os import json import pytest +from unittest.mock import patch # Setup path to import the service FILE = __file__ @@ -91,23 +92,34 @@ def test_empty_order_still_returns_suggestions(self): assert len(response.books) == 3 def test_multiple_calls_may_return_different_books(self): - """Test that suggestions are randomized (multiple calls may differ)""" + """Test that suggestions use random.sample for randomization""" order = { "user": {"name": "John Doe", "contact": "john@example.com"}, "items": [{"name": "Book A", "quantity": 2}] } request = sg_pb2.SuggestionsRequest(order_json=json.dumps(order)) - # Call 10 times and collect all book IDs - all_book_ids = set() - for _ in range(10): - response = self.service.GetSuggestions(request, self.context) - for book in response.books: - all_book_ids.add(book.book_id) - - # With randomization, we should see more than 3 different books across 10 calls - # (This test might occasionally fail due to randomness, but probability is very low) - assert len(all_book_ids) > 3, "Suggestions don't appear to be randomized" + # Mock random.sample to return predictable results + with patch('app.random.sample') as mock_sample: + # Configure mock to return different sets of books on each call + mock_sample.side_effect = [ + [BOOK_CATALOG[0], BOOK_CATALOG[1], BOOK_CATALOG[2]], # First call + [BOOK_CATALOG[3], BOOK_CATALOG[4], BOOK_CATALOG[5]], # Second call + ] + + # First call + response1 = self.service.GetSuggestions(request, self.context) + assert len(response1.books) == 3 + assert response1.books[0].book_id == "101" + + # Second call + response2 = self.service.GetSuggestions(request, self.context) + assert len(response2.books) == 3 + assert response2.books[0].book_id == "104" + + # Verify random.sample was called with correct arguments + assert mock_sample.call_count == 2 + mock_sample.assert_called_with(BOOK_CATALOG, 3) def test_book_catalog_has_expected_size(self): """Test that BOOK_CATALOG contains expected number of books"""