diff --git a/cdk/service/api_construct.py b/cdk/service/api_construct.py index 1deacc3a..bb6d9eb9 100644 --- a/cdk/service/api_construct.py +++ b/cdk/service/api_construct.py @@ -25,8 +25,11 @@ def __init__(self, scope: Construct, id_: str, appconfig_app_name: str, is_produ self.create_order_func = self._add_post_lambda_integration( orders_resource, self.lambda_role, self.api_db.db, appconfig_app_name, self.api_db.idempotency_db ) + self.delete_order_func = self._add_delete_lambda_integration( + orders_resource, self.lambda_role, self.api_db.db, appconfig_app_name, self.api_db.idempotency_db + ) self._build_swagger_endpoints(rest_api=self.rest_api, dest_func=self.create_order_func) - self.monitoring = CrudMonitoring(self, id_, self.rest_api, self.api_db.db, self.api_db.idempotency_db, [self.create_order_func]) + self.monitoring = CrudMonitoring(self, id_, self.rest_api, self.api_db.db, self.api_db.idempotency_db, [self.create_order_func, self.delete_order_func]) if is_production_env: # add WAF @@ -76,7 +79,7 @@ def _build_lambda_role(self, db: dynamodb.TableV2, idempotency_table: dynamodb.T 'dynamodb_db': iam.PolicyDocument( statements=[ iam.PolicyStatement( - actions=['dynamodb:PutItem'], + actions=['dynamodb:PutItem', 'dynamodb:DeleteItem'], resources=[db.table_arn], effect=iam.Effect.ALLOW, ) @@ -146,3 +149,39 @@ def _add_post_lambda_integration( # POST /api/orders/ api_resource.add_method(http_method='POST', integration=aws_apigateway.LambdaIntegration(handler=lambda_function)) return lambda_function + + def _add_delete_lambda_integration( + self, + api_resource: aws_apigateway.Resource, + role: iam.Role, + db: dynamodb.TableV2, + appconfig_app_name: str, + idempotency_table: dynamodb.TableV2, + ) -> _lambda.Function: + lambda_function = _lambda.Function( + self, + constants.DELETE_LAMBDA, + runtime=_lambda.Runtime.PYTHON_3_13, + code=_lambda.Code.from_asset(constants.BUILD_FOLDER), + handler='service.handlers.handle_delete_order.lambda_handler', + environment={ + constants.POWERTOOLS_SERVICE_NAME: constants.SERVICE_NAME, # for logger, tracer and metrics + constants.POWER_TOOLS_LOG_LEVEL: 'INFO', # for logger + 'TABLE_NAME': db.table_name, + 'IDEMPOTENCY_TABLE_NAME': idempotency_table.table_name, + }, + tracing=_lambda.Tracing.ACTIVE, + retry_attempts=0, + timeout=Duration.seconds(constants.API_HANDLER_LAMBDA_TIMEOUT), + memory_size=constants.API_HANDLER_LAMBDA_MEMORY_SIZE, + layers=[self.common_layer], + role=role, + log_retention=RetentionDays.ONE_DAY, + logging_format=_lambda.LoggingFormat.JSON, + system_log_level_v2=_lambda.SystemLogLevel.INFO, + ) + + # DELETE /api/orders/{order_id} + order_resource = api_resource.add_resource('{order_id}') + order_resource.add_method(http_method='DELETE', integration=aws_apigateway.LambdaIntegration(handler=lambda_function)) + return lambda_function diff --git a/cdk/service/constants.py b/cdk/service/constants.py index 39b0c16d..44ca284c 100644 --- a/cdk/service/constants.py +++ b/cdk/service/constants.py @@ -2,6 +2,7 @@ LAMBDA_BASIC_EXECUTION_ROLE = 'AWSLambdaBasicExecutionRole' SERVICE_ROLE = 'ServiceRole' CREATE_LAMBDA = 'CreateOrder' +DELETE_LAMBDA = 'DeleteOrder' TABLE_NAME = 'orders' IDEMPOTENCY_TABLE_NAME = 'Idempotency' TABLE_NAME_OUTPUT = 'DbOutput' diff --git a/service/dal/db_handler.py b/service/dal/db_handler.py index f6824606..1b766be8 100644 --- a/service/dal/db_handler.py +++ b/service/dal/db_handler.py @@ -16,3 +16,6 @@ def __call__(cls, *args, **kwargs): class DalHandler(ABC, metaclass=_SingletonMeta): @abstractmethod def create_order_in_db(self, customer_name: str, order_item_count: int) -> Order: ... # pragma: no cover + + @abstractmethod + def delete_order_in_db(self, order_id: str) -> Order: ... # pragma: no cover diff --git a/service/dal/dynamo_dal_handler.py b/service/dal/dynamo_dal_handler.py index 6cd12bb7..081961bb 100644 --- a/service/dal/dynamo_dal_handler.py +++ b/service/dal/dynamo_dal_handler.py @@ -50,3 +50,32 @@ def create_order_in_db(self, customer_name: str, order_item_count: int) -> Order logger.info('finished create order successfully', order_item_count=order_item_count, customer_name=customer_name) return Order(id=entry.id, name=entry.name, item_count=entry.item_count) + + @tracer.capture_method(capture_response=False) + def delete_order_in_db(self, order_id: str) -> Order: + logger.append_keys(order_id=order_id) + logger.info('trying to delete order', order_id=order_id) + try: + table: Table = self._get_db_handler(self.table_name) + response = table.delete_item( + Key={'id': order_id}, + ReturnValues='ALL_OLD' + ) + + if 'Attributes' not in response: + error_msg = f'Order with ID {order_id} not found' + logger.error(error_msg) + raise InternalServerException(error_msg) + + attributes = response['Attributes'] + deleted_order = Order( + id=attributes['id'], + name=attributes['name'], + item_count=attributes['item_count'] + ) + logger.info('finished delete order successfully', order_id=order_id) + return deleted_order + except (ClientError, ValidationError) as exc: # pragma: no cover + error_msg = 'failed to delete order' + logger.exception(error_msg, order_id=order_id) + raise InternalServerException(error_msg) from exc diff --git a/service/handlers/handle_delete_order.py b/service/handlers/handle_delete_order.py new file mode 100644 index 00000000..5bd31a4c --- /dev/null +++ b/service/handlers/handle_delete_order.py @@ -0,0 +1,58 @@ +from typing import Annotated, Any + +from aws_lambda_env_modeler import get_environment_variables, init_environment_variables +from aws_lambda_powertools.event_handler.openapi.params import Path +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.metrics import MetricUnit +from aws_lambda_powertools.utilities.typing import LambdaContext + +from service.handlers.models.env_vars import MyHandlerEnvVars +from service.handlers.utils.observability import logger, metrics, tracer +from service.handlers.utils.rest_api_resolver import ORDERS_PATH, app +from service.logic.delete_order import delete_order +from service.models.input import DeleteOrderRequest +from service.models.output import DeleteOrderOutput, InternalServerErrorOutput + + +@app.delete( + f"{ORDERS_PATH}{{order_id}}", + summary='Delete an order', + description='Delete an order identified by the order_id', + response_description='The deleted order', + responses={ + 200: { + 'description': 'The deleted order', + 'content': {'application/json': {'model': DeleteOrderOutput}}, + }, + 501: { + 'description': 'Internal server error', + 'content': {'application/json': {'model': InternalServerErrorOutput}}, + }, + }, + tags=['CRUD'], +) +def handle_delete_order(order_id: Annotated[str, Path()]) -> DeleteOrderOutput: + env_vars: MyHandlerEnvVars = get_environment_variables(model=MyHandlerEnvVars) + logger.debug('environment variables', env_vars=env_vars.model_dump()) + logger.info('got delete order request', order_id=order_id) + + # Create request model + delete_input = DeleteOrderRequest(order_id=order_id) + + metrics.add_metric(name='ValidDeleteOrderEvents', unit=MetricUnit.Count, value=1) + response: DeleteOrderOutput = delete_order( + order_request=delete_input, + table_name=env_vars.TABLE_NAME, + context=app.lambda_context, + ) + + logger.info('finished handling delete order request') + return response + + +@init_environment_variables(model=MyHandlerEnvVars) +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) +@metrics.log_metrics +@tracer.capture_lambda_handler(capture_response=False) +def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]: + return app.resolve(event, context) \ No newline at end of file diff --git a/service/handlers/test_handle_delete_order.py b/service/handlers/test_handle_delete_order.py new file mode 100644 index 00000000..6db6c15d --- /dev/null +++ b/service/handlers/test_handle_delete_order.py @@ -0,0 +1,54 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.event_handler.openapi.params import Path + +from service.handlers.handle_delete_order import handle_delete_order, lambda_handler +from service.models.input import DeleteOrderRequest +from service.models.output import DeleteOrderOutput + + +@patch('service.handlers.handle_delete_order.delete_order') +@patch('service.handlers.handle_delete_order.app') +@patch('service.handlers.handle_delete_order.get_environment_variables') +@patch('service.handlers.handle_delete_order.parse_configuration') +def test_handle_delete_order_success(mock_parse_config, mock_get_env_vars, mock_app, mock_delete_order): + # Setup + order_id = "12345678-1234-1234-1234-123456789012" + mock_env_vars = MagicMock() + mock_env_vars.TABLE_NAME = "test-table" + mock_get_env_vars.return_value = mock_env_vars + + # Set up the expected return value from delete_order + expected_output = DeleteOrderOutput(id=order_id, name="Test Customer", item_count=5) + mock_delete_order.return_value = expected_output + + # Execute + result = handle_delete_order(order_id=order_id) + + # Verify + assert result == expected_output + + # Check that delete_order was called with correct parameters + mock_delete_order.assert_called_once() + called_args = mock_delete_order.call_args.kwargs + assert isinstance(called_args['delete_request'], DeleteOrderRequest) + assert called_args['delete_request'].order_id == order_id + assert called_args['table_name'] == mock_env_vars.TABLE_NAME + assert called_args['context'] == mock_app.lambda_context + + +@patch('service.handlers.handle_delete_order.app') +def test_lambda_handler(mock_app): + # Setup + event = {'some': 'event'} + context = {'some': 'context'} + mock_app.resolve.return_value = {'some': 'response'} + + # Execute + response = lambda_handler(event=event, context=context) + + # Verify + mock_app.resolve.assert_called_once_with(event, context) + assert response == {'some': 'response'} \ No newline at end of file diff --git a/service/logic/delete_order.py b/service/logic/delete_order.py new file mode 100644 index 00000000..472e4caf --- /dev/null +++ b/service/logic/delete_order.py @@ -0,0 +1,35 @@ +from aws_lambda_powertools.utilities.idempotency import idempotent_function +from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import PydanticSerializer +from aws_lambda_powertools.utilities.typing import LambdaContext + +from service.dal import get_dal_handler +from service.dal.db_handler import DalHandler +from service.handlers.utils.observability import logger, tracer +from service.logic.utils.idempotency import IDEMPOTENCY_CONFIG, IDEMPOTENCY_LAYER +from service.models.input import DeleteOrderRequest +from service.models.order import Order +from service.models.output import DeleteOrderOutput + + +@idempotent_function( + data_keyword_argument='order_request', + config=IDEMPOTENCY_CONFIG, + persistence_store=IDEMPOTENCY_LAYER, + output_serializer=PydanticSerializer, +) +@tracer.capture_method(capture_response=False) +def delete_order(order_request: DeleteOrderRequest, table_name: str, context: LambdaContext) -> DeleteOrderOutput: + IDEMPOTENCY_CONFIG.register_lambda_context(context) # see Lambda timeouts section + + logger.info('starting to handle delete request', order_id=order_request.order_id) + + # get the data access layer handler + dal_handler: DalHandler = get_dal_handler(table_name=table_name) + + # delete order in database + order: Order = dal_handler.delete_order_in_db(order_id=order_request.order_id) + + # create response + response = DeleteOrderOutput(**order.model_dump()) + logger.info('successfully handled delete order request') + return response \ No newline at end of file diff --git a/service/logic/test_delete_order.py b/service/logic/test_delete_order.py new file mode 100644 index 00000000..3de6c99e --- /dev/null +++ b/service/logic/test_delete_order.py @@ -0,0 +1,75 @@ +import uuid +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.utilities.idempotency import IdempotencyConfig, idempotency_function +from aws_lambda_powertools.utilities.typing import LambdaContext + +from service.dal.db_handler import DalHandler +from service.logic.delete_order import delete_order +from service.models.input import DeleteOrderRequest +from service.models.order import Order +from service.models.output import DeleteOrderOutput + + +@pytest.fixture +def lambda_context(): + return MagicMock(spec=LambdaContext) + + +@pytest.fixture +def mock_dal_handler(): + dal_handler = MagicMock(spec=DalHandler) + return dal_handler + + +@pytest.fixture +def delete_request(): + return DeleteOrderRequest(order_id="12345678-1234-1234-1234-123456789012") + + +@patch('service.logic.delete_order.idempotent_function') +@patch('service.logic.delete_order.get_dal_handler') +def test_successful_delete_order(mock_get_dal_handler, mock_idempotent, mock_dal_handler, delete_request, lambda_context): + # Setup + # Mock idempotent_function decorator to call the function directly + mock_idempotent.side_effect = lambda *args, **kwargs: kwargs.get('data_keyword_argument') and idempotency_function(**kwargs) or args[0] + + # Mock the DAL handler to return an order + mock_order = Order(id=delete_request.order_id, name="Test Customer", item_count=5) + mock_dal_handler.delete_order.return_value = mock_order + mock_get_dal_handler.return_value = mock_dal_handler + + # Execute + result = delete_order(delete_request=delete_request, table_name="test-table", context=lambda_context) + + # Verify + assert result.id == mock_order.id + assert result.name == mock_order.name + assert result.item_count == mock_order.item_count + + # Check that the DAL handler was called with the correct order ID + mock_dal_handler.delete_order.assert_called_once_with(order_id=delete_request.order_id) + + +@patch('service.logic.delete_order.idempotent_function') +@patch('service.logic.delete_order.get_dal_handler') +def test_delete_order_not_found(mock_get_dal_handler, mock_idempotent, mock_dal_handler, delete_request, lambda_context): + # Setup + # Mock idempotent_function decorator to call the function directly + mock_idempotent.side_effect = lambda *args, **kwargs: kwargs.get('data_keyword_argument') and idempotency_function(**kwargs) or args[0] + + # Mock the DAL handler to return None, indicating no order was found + mock_dal_handler.delete_order.return_value = None + mock_get_dal_handler.return_value = mock_dal_handler + + # Execute + result = delete_order(delete_request=delete_request, table_name="test-table", context=lambda_context) + + # Verify + assert result.id == delete_request.order_id + assert result.name == "Order not found" + assert result.item_count == 0 + + # Check that the DAL handler was called with the correct order ID + mock_dal_handler.delete_order.assert_called_once_with(order_id=delete_request.order_id) \ No newline at end of file diff --git a/service/models/exceptions.py b/service/models/exceptions.py index 646d8207..accd4e5a 100644 --- a/service/models/exceptions.py +++ b/service/models/exceptions.py @@ -1,6 +1,13 @@ class InternalServerException(Exception): + """Raised when an unexpected error occurs in the server""" + pass + + +class OrderNotFoundException(Exception): + """Raised when trying to access an order that doesn't exist""" pass class DynamicConfigurationException(Exception): + """Raised when AppConfig fails to return configuration data""" pass diff --git a/service/models/input.py b/service/models/input.py index 7d04d290..6d0bde51 100644 --- a/service/models/input.py +++ b/service/models/input.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, Field, field_validator +from service.models.order import OrderId + class CreateOrderRequest(BaseModel): customer_name: Annotated[str, Field(min_length=1, max_length=20, description='Customer name')] @@ -15,3 +17,7 @@ def check_order_item_count(cls, v): if v <= 0: raise ValueError('order_item_count must be larger than 0') return v + + +class DeleteOrderRequest(BaseModel): + order_id: OrderId diff --git a/service/models/output.py b/service/models/output.py index 73af787f..fd2c6a4f 100644 --- a/service/models/output.py +++ b/service/models/output.py @@ -12,5 +12,9 @@ class CreateOrderOutput(Order): pass +class DeleteOrderOutput(Order): + """Output payload for delete order operation.""" + + class InternalServerErrorOutput(BaseModel): error: Annotated[str, Field(description='Error description')] = 'internal server error' diff --git a/service/models/test_input.py b/service/models/test_input.py new file mode 100644 index 00000000..10b18e94 --- /dev/null +++ b/service/models/test_input.py @@ -0,0 +1,90 @@ +import pytest +from pydantic import ValidationError + +from service.models.input import CreateOrderRequest, DeleteOrderRequest + + +class TestCreateOrderRequest: + def test_valid_create_order_request(self): + # Given valid inputs + customer_name = "TestCustomer" + order_item_count = 5 + + # When creating a valid request + request = CreateOrderRequest( + customer_name=customer_name, + order_item_count=order_item_count + ) + + # Then the request should have the expected values + assert request.customer_name == customer_name + assert request.order_item_count == order_item_count + + def test_invalid_customer_name(self): + # Given an empty customer name + customer_name = "" + order_item_count = 5 + + # When trying to create a request with invalid customer name + # Then it should raise a validation error + with pytest.raises(ValidationError): + CreateOrderRequest( + customer_name=customer_name, + order_item_count=order_item_count + ) + + def test_invalid_order_item_count(self): + # Given a negative order item count + customer_name = "TestCustomer" + order_item_count = -1 + + # When trying to create a request with invalid order item count + # Then it should raise a validation error + with pytest.raises(ValidationError): + CreateOrderRequest( + customer_name=customer_name, + order_item_count=order_item_count + ) + + def test_zero_order_item_count(self): + # Given a zero order item count + customer_name = "TestCustomer" + order_item_count = 0 + + # When trying to create a request with zero order item count + # Then it should raise a validation error + with pytest.raises(ValidationError): + CreateOrderRequest( + customer_name=customer_name, + order_item_count=order_item_count + ) + + +class TestDeleteOrderRequest: + def test_valid_delete_order_request(self): + # Given a valid order ID (UUID format) + order_id = "12345678-1234-1234-1234-123456789012" + + # When creating a valid delete request + request = DeleteOrderRequest(order_id=order_id) + + # Then the request should have the expected value + assert request.order_id == order_id + + def test_invalid_order_id_too_short(self): + # Given an order ID that's too short + order_id = "12345" + + # When trying to create a request with an invalid order ID + # Then it should raise a validation error + with pytest.raises(ValidationError): + DeleteOrderRequest(order_id=order_id) + + def test_invalid_order_id_too_long(self): + # Given an order ID that's too long + order_id = "12345678-1234-1234-1234-1234567890123456" + + # When trying to create a request with an invalid order ID + # Then it should raise a validation error + with pytest.raises(ValidationError): + DeleteOrderRequest(order_id=order_id) \ No newline at end of file diff --git a/service/models/test_output.py b/service/models/test_output.py new file mode 100644 index 00000000..8c00d556 --- /dev/null +++ b/service/models/test_output.py @@ -0,0 +1,93 @@ +import pytest +from pydantic import ValidationError + +from service.models.output import CreateOrderOutput, DeleteOrderOutput, InternalServerErrorOutput + + +class TestCreateOrderOutput: + def test_valid_create_order_output(self): + # Given valid inputs + name = "TestCustomer" + item_count = 5 + order_id = "12345678-1234-1234-1234-123456789012" + + # When creating a valid output + output = CreateOrderOutput( + name=name, + item_count=item_count, + id=order_id + ) + + # Then the output should have the expected values + assert output.name == name + assert output.item_count == item_count + assert output.id == order_id + + def test_invalid_item_count(self): + # Given an invalid item count + name = "TestCustomer" + item_count = -1 + order_id = "12345678-1234-1234-1234-123456789012" + + # When trying to create an output with invalid item count + # Then it should raise a validation error + with pytest.raises(ValidationError): + CreateOrderOutput( + name=name, + item_count=item_count, + id=order_id + ) + + +class TestDeleteOrderOutput: + def test_valid_delete_order_output(self): + # Given valid inputs + name = "TestCustomer" + item_count = 5 + order_id = "12345678-1234-1234-1234-123456789012" + + # When creating a valid output + output = DeleteOrderOutput( + name=name, + item_count=item_count, + id=order_id + ) + + # Then the output should have the expected values + assert output.name == name + assert output.item_count == item_count + assert output.id == order_id + + def test_invalid_id(self): + # Given an invalid order ID + name = "TestCustomer" + item_count = 5 + order_id = "invalid-id" + + # When trying to create an output with invalid ID + # Then it should raise a validation error + with pytest.raises(ValidationError): + DeleteOrderOutput( + name=name, + item_count=item_count, + id=order_id + ) + + +class TestInternalServerErrorOutput: + def test_default_error_message(self): + # When creating a default error output + error_output = InternalServerErrorOutput() + + # Then it should have the default error message + assert error_output.error == "internal server error" + + def test_custom_error_message(self): + # Given a custom error message + custom_error = "custom error message" + + # When creating an error output with a custom message + error_output = InternalServerErrorOutput(error=custom_error) + + # Then it should have the custom error message + assert error_output.error == custom_error \ No newline at end of file diff --git a/service/tests/e2e/test_delete_order.py b/service/tests/e2e/test_delete_order.py new file mode 100644 index 00000000..f3497b68 --- /dev/null +++ b/service/tests/e2e/test_delete_order.py @@ -0,0 +1,94 @@ +import json +import uuid +from typing import Dict + +import pytest +import requests + +from service.models.order import Order + + +@pytest.fixture(scope='module') +def api_gw_url(): + import os + url = os.environ.get('ORDER_API_GW_URL') + if not url: + raise ValueError('Missing environment variable: ORDER_API_GW_URL') + return url + + +def test_delete_order_flow(api_gw_url): + # First create an order to delete + customer_name = 'E2E Test Customer' + order_item_count = 3 + + # Create order + create_url = f"{api_gw_url}/api/orders/" + create_response = requests.post( + create_url, + json={ + 'customer_name': customer_name, + 'order_item_count': order_item_count + } + ) + + assert create_response.status_code == 200 + created_order = create_response.json() + order_id = created_order['id'] + + # Delete the order + delete_url = f"{api_gw_url}/api/orders/delete" + delete_response = requests.post( + delete_url, + json={ + 'order_id': order_id + } + ) + + # Check the response + assert delete_response.status_code == 200 + deleted_order = delete_response.json() + assert deleted_order['id'] == order_id + assert deleted_order['name'] == customer_name + assert deleted_order['item_count'] == order_item_count + + # Try to delete the same order again, should get a 404 + delete_again_response = requests.post( + delete_url, + json={ + 'order_id': order_id + } + ) + + assert delete_again_response.status_code == 404 + assert delete_again_response.json()['error'] == 'order not found' + + +def test_delete_nonexistent_order(api_gw_url): + delete_url = f"{api_gw_url}/api/orders/delete" + nonexistent_order_id = str(uuid.uuid4()) + + response = requests.post( + delete_url, + json={ + 'order_id': nonexistent_order_id + } + ) + + assert response.status_code == 404 + assert response.json()['error'] == 'order not found' + + +def test_delete_invalid_order_id(api_gw_url): + delete_url = f"{api_gw_url}/api/orders/delete" + + # Test with an invalid UUID + response = requests.post( + delete_url, + json={ + 'order_id': 'not-a-uuid' + } + ) + + # Should get a validation error + assert response.status_code == 422 \ No newline at end of file diff --git a/service/tests/integration/test_delete_order.py b/service/tests/integration/test_delete_order.py new file mode 100644 index 00000000..66b674a7 --- /dev/null +++ b/service/tests/integration/test_delete_order.py @@ -0,0 +1,89 @@ +import json +import uuid +from typing import Any, Dict +from unittest.mock import patch + +import boto3 +import pytest +from botocore.stub import Stubber +from mypy_boto3_dynamodb.service_resource import Table + +from service.dal import DynamoDalHandler +from service.handlers.handle_delete_order import lambda_handler +from service.models.exceptions import OrderNotFoundException, InternalServerException + + +def call_delete_order(body: Dict[str, Any]) -> Dict[str, Any]: + event = { + 'body': json.dumps(body), + 'httpMethod': 'POST', + 'path': '/api/orders/delete', + 'requestContext': {'requestId': '227b78aa-779d-47d4-a48a-e83c31501c64'}, + } + response = lambda_handler(event=event, context=None) + return response + + +def test_handler_200_ok(mocker, table_name: str): + # Create a real order in DynamoDB + order_id = str(uuid.uuid4()) + customer_name = "Integration Test Customer" + item_count = 10 + + # Create the item to be used in tests + table: Table = boto3.resource('dynamodb').Table(table_name) + table.put_item( + Item={ + 'id': order_id, + 'name': customer_name, + 'item_count': item_count, + 'created_at': 1234567890, + } + ) + + # Call delete order handler + result = call_delete_order({'order_id': order_id}) + + # Verify the response + assert result['statusCode'] == 200 + assert json.loads(result['body'])['id'] == order_id + assert json.loads(result['body'])['name'] == customer_name + assert json.loads(result['body'])['item_count'] == item_count + + # Verify the item was actually deleted + response = table.get_item(Key={'id': order_id}) + assert 'Item' not in response + + +def test_handler_404_not_found(mocker, table_name: str): + # Use a UUID that doesn't exist + order_id = str(uuid.uuid4()) + + # Call delete order handler for non-existent order + result = call_delete_order({'order_id': order_id}) + + # Verify the response + assert result['statusCode'] == 404 + assert json.loads(result['body'])['error'] == 'order not found' + + +def test_internal_server_error(mocker, table_name: str): + # Mock DynamoDB client to simulate a ClientError + order_id = str(uuid.uuid4()) + + # Get the table + table: Table = boto3.resource('dynamodb').Table(table_name) + + with Stubber(table.meta.client) as stubber: + # Stub the get_item method to raise an exception + stubber.add_client_error('get_item', service_error_code='InternalServerError') + + # Mock get_db_handler to return our stubbed table + mocker.patch.object(DynamoDalHandler, '_get_db_handler', return_value=table) + + # Call delete order with the stubbed client + result = call_delete_order({'order_id': order_id}) + + # Verify the response + assert result['statusCode'] == 501 + assert json.loads(result['body'])['error'] == 'internal server error' \ No newline at end of file diff --git a/service/tests/unit/test_delete_order.py b/service/tests/unit/test_delete_order.py new file mode 100644 index 00000000..65f0c52b --- /dev/null +++ b/service/tests/unit/test_delete_order.py @@ -0,0 +1,67 @@ +import json +import uuid +from typing import Dict +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.utilities.typing import LambdaContext + +from service.handlers.handle_delete_order import lambda_handler +from service.models.exceptions import OrderNotFoundException +from service.models.order import Order + + +@pytest.fixture +def lambda_context(): + return MagicMock(spec=LambdaContext) + + +@pytest.fixture +def order_id(): + return str(uuid.uuid4()) + + +@pytest.fixture +def delete_event(order_id): + return { + 'body': json.dumps({'order_id': order_id}), + 'httpMethod': 'POST', + 'path': '/api/orders/delete', + 'requestContext': {'requestId': '227b78aa-779d-47d4-a48a-e83c31501c64'}, + } + + +def test_delete_order_handler_success(delete_event, lambda_context, monkeypatch): + # Mock environment variables + monkeypatch.setenv('TABLE_NAME', 'test_table') + + order_id = json.loads(delete_event['body'])['order_id'] + order = Order(id=order_id, name="Test Customer", item_count=5) + + # Mock delete_order function + with patch('service.handlers.handle_delete_order.delete_order') as mock_delete_order: + mock_delete_order.return_value = order + + # Invoke the handler + result = lambda_handler(delete_event, lambda_context) + + # Verify the expected behavior + assert result['statusCode'] == 200 + assert json.loads(result['body'])['id'] == order_id + mock_delete_order.assert_called_once() + + +def test_delete_order_handler_not_found(delete_event, lambda_context, monkeypatch): + # Mock environment variables + monkeypatch.setenv('TABLE_NAME', 'test_table') + + # Mock delete_order function to raise OrderNotFoundException + with patch('service.handlers.handle_delete_order.delete_order') as mock_delete_order: + mock_delete_order.side_effect = OrderNotFoundException("Order not found") + + # Invoke the handler + result = lambda_handler(delete_event, lambda_context) + + # Verify the expected behavior + assert result['statusCode'] == 404 + assert json.loads(result['body'])['error'] == 'order not found' \ No newline at end of file