diff --git a/unicorn_approvals/ApprovalsService/pom.xml b/unicorn_approvals/ApprovalsService/pom.xml index 4d00291..77036fa 100644 --- a/unicorn_approvals/ApprovalsService/pom.xml +++ b/unicorn_approvals/ApprovalsService/pom.xml @@ -14,7 +14,8 @@ 2.9.0 3.16.1 5.23.0 - 4.13.2 + 5.12.2 + 5.23.0 1.1.2 1.4.0 2.42.13 @@ -135,9 +136,15 @@ test - junit - junit - ${junit.version} + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} test @@ -166,7 +173,7 @@ org.apache.maven.surefire - surefire-junit4 + surefire-junit-platform 3.5.5 @@ -238,6 +245,52 @@ 21 + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + approvals/dao/** + schema/** + approvals/ContractStatusNotFoundException* + + + + + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + \ No newline at end of file diff --git a/unicorn_approvals/ApprovalsService/src/test/events/lambda/wait_for_contract_approval.json b/unicorn_approvals/ApprovalsService/src/test/events/lambda/wait_for_contract_approval.json new file mode 100644 index 0000000..d979eab --- /dev/null +++ b/unicorn_approvals/ApprovalsService/src/test/events/lambda/wait_for_contract_approval.json @@ -0,0 +1,6 @@ +{ + "TaskToken": "test-task-token-abc123", + "Input": { + "property_id": "usa/anytown/main-street/123" + } +} diff --git a/unicorn_approvals/ApprovalsService/src/test/java/approvals/ContractStatusTests.java b/unicorn_approvals/ApprovalsService/src/test/java/approvals/ContractStatusTests.java index c8e01da..2037baf 100644 --- a/unicorn_approvals/ApprovalsService/src/test/java/approvals/ContractStatusTests.java +++ b/unicorn_approvals/ApprovalsService/src/test/java/approvals/ContractStatusTests.java @@ -1,32 +1,39 @@ package approvals; import com.amazonaws.services.lambda.runtime.Context; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.DynamoDbException; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import java.io.*; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class ContractStatusTests { @Mock private Context context; - + @Mock private DynamoDbClient dynamoDbClient; private ContractStatusChangedHandlerFunction contractStatusChangedHandler; - @Before + @BeforeEach public void setUp() { contractStatusChangedHandler = new ContractStatusChangedHandlerFunction(); contractStatusChangedHandler.setDynamodbClient(dynamoDbClient); @@ -36,16 +43,44 @@ public void setUp() { public void shouldProcessValidContractStatusChangeEvent() throws IOException { // Given Path testEventPath = Paths.get("src/test/events/lambda/contract_status_changed.json"); - + // When try (InputStream inputStream = Files.newInputStream(testEventPath); ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - + contractStatusChangedHandler.handleRequest(inputStream, outputStream, context); - + // Then String response = outputStream.toString(); - assertTrue("Response should contain contract_id", response.contains("contract_id")); + assertTrue(response.contains("contract_id")); + } + } + + @Test + public void shouldHandleMalformedEvent() { + // Given + String malformedJson = "{ not valid json }"; + InputStream inputStream = new ByteArrayInputStream(malformedJson.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When / Then + assertThrows(Exception.class, () -> + contractStatusChangedHandler.handleRequest(inputStream, outputStream, context)); + } + + @Test + public void shouldHandleDynamoDbFailure() throws IOException { + // Given + Path testEventPath = Paths.get("src/test/events/lambda/contract_status_changed.json"); + when(dynamoDbClient.updateItem(any(UpdateItemRequest.class))) + .thenThrow(DynamoDbException.builder().message("DynamoDB failure").build()); + + // When / Then + try (InputStream inputStream = Files.newInputStream(testEventPath); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + + assertThrows(DynamoDbException.class, () -> + contractStatusChangedHandler.handleRequest(inputStream, outputStream, context)); } } } diff --git a/unicorn_approvals/ApprovalsService/src/test/java/approvals/PropertiesApprovalSyncFunctionTests.java b/unicorn_approvals/ApprovalsService/src/test/java/approvals/PropertiesApprovalSyncFunctionTests.java new file mode 100644 index 0000000..8d5e1d1 --- /dev/null +++ b/unicorn_approvals/ApprovalsService/src/test/java/approvals/PropertiesApprovalSyncFunctionTests.java @@ -0,0 +1,118 @@ +package approvals; + +import approvals.helpers.TestHelpers; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; +import com.amazonaws.services.lambda.runtime.events.StreamsEventResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.sfn.SfnAsyncClient; +import software.amazon.awssdk.services.sfn.model.SendTaskSuccessRequest; +import software.amazon.awssdk.services.sfn.model.SendTaskSuccessResponse; + +import java.lang.reflect.Field; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class PropertiesApprovalSyncFunctionTests { + + @Mock + private Context context; + + @Mock + private SfnAsyncClient sfnAsyncClient; + + private PropertiesApprovalSyncFunction handler; + + @BeforeEach + public void setUp() throws Exception { + handler = new PropertiesApprovalSyncFunction(); + // Inject the mock SFN client via reflection + Field sfnField = PropertiesApprovalSyncFunction.class.getDeclaredField("sfnClient"); + sfnField.setAccessible(true); + sfnField.set(handler, sfnAsyncClient); + } + + @Test + public void shouldSendTaskSuccessWhenApprovedWithToken() { + // Given + DynamodbEvent event = TestHelpers.createDynamoDbStreamEvent( + "usa/anytown/main-street/111", + "contract-001", + "APPROVED", + "DRAFT", + "test-task-token-123", + null + ); + + when(sfnAsyncClient.sendTaskSuccess(any(SendTaskSuccessRequest.class))) + .thenReturn(CompletableFuture.completedFuture(SendTaskSuccessResponse.builder().build())); + + // When + StreamsEventResponse response = handler.handleRequest(event, context); + + // Then + assertTrue(response.getBatchItemFailures() == null || response.getBatchItemFailures().isEmpty()); + verify(sfnAsyncClient, times(1)).sendTaskSuccess(any(SendTaskSuccessRequest.class)); + } + + @Test + public void shouldSkipRecordWithoutTaskToken() { + // Given - no task token in either NewImage or OldImage + DynamodbEvent event = TestHelpers.createDynamoDbStreamEvent( + "usa/anytown/main-street/111", + "contract-001", + "APPROVED", + null, + null, + null + ); + + // When + StreamsEventResponse response = handler.handleRequest(event, context); + + // Then + assertTrue(response.getBatchItemFailures() == null || response.getBatchItemFailures().isEmpty()); + verifyNoInteractions(sfnAsyncClient); + } + + @Test + public void shouldSkipRecordWithNonApprovedStatus() { + // Given - status is DRAFT, not APPROVED + DynamodbEvent event = TestHelpers.createDynamoDbStreamEvent( + "usa/anytown/main-street/111", + "contract-001", + "DRAFT", + null, + "test-task-token-123", + null + ); + + // When + StreamsEventResponse response = handler.handleRequest(event, context); + + // Then + assertTrue(response.getBatchItemFailures() == null || response.getBatchItemFailures().isEmpty()); + verifyNoInteractions(sfnAsyncClient); + } + + @Test + public void shouldSkipRecordWithMissingNewImage() { + // Given + DynamodbEvent event = TestHelpers.createDynamoDbStreamEventWithNullNewImage(); + + // When + StreamsEventResponse response = handler.handleRequest(event, context); + + // Then + assertTrue(response.getBatchItemFailures() == null || response.getBatchItemFailures().isEmpty()); + verifyNoInteractions(sfnAsyncClient); + } +} diff --git a/unicorn_approvals/ApprovalsService/src/test/java/approvals/WaitForContractApprovalFunctionTests.java b/unicorn_approvals/ApprovalsService/src/test/java/approvals/WaitForContractApprovalFunctionTests.java new file mode 100644 index 0000000..e2228de --- /dev/null +++ b/unicorn_approvals/ApprovalsService/src/test/java/approvals/WaitForContractApprovalFunctionTests.java @@ -0,0 +1,125 @@ +package approvals; + +import approvals.helpers.TestHelpers; +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DynamoDbException; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class WaitForContractApprovalFunctionTests { + + @Mock + private Context context; + + @Mock + private DynamoDbAsyncClient dynamoDbAsyncClient; + + private WaitForContractApprovalFunction handler; + + @BeforeEach + public void setUp() throws Exception { + handler = new WaitForContractApprovalFunction(); + // Inject the mock DynamoDB async client via reflection + Field dynamoField = WaitForContractApprovalFunction.class.getDeclaredField("dynamodbClient"); + dynamoField.setAccessible(true); + dynamoField.set(handler, dynamoDbAsyncClient); + } + + @Test + public void shouldStoreTaskTokenAndReturnInput() throws Exception { + // Given + String propertyId = "usa/anytown/main-street/123"; + String taskToken = "test-task-token-abc"; + String eventJson = TestHelpers.createStepFunctionsEvent(taskToken, propertyId); + + Map item = Map.of( + "property_id", AttributeValue.fromS(propertyId), + "contract_status", AttributeValue.fromS("DRAFT") + ); + + when(dynamoDbAsyncClient.getItem(any(GetItemRequest.class))) + .thenReturn(CompletableFuture.completedFuture( + GetItemResponse.builder().item(item).build())); + + when(dynamoDbAsyncClient.updateItem(any(UpdateItemRequest.class))) + .thenReturn(CompletableFuture.completedFuture( + UpdateItemResponse.builder().build())); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(eventJson.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When + handler.handleRequest(inputStream, outputStream, context); + + // Then + String response = outputStream.toString(StandardCharsets.UTF_8); + assertTrue(response.contains(propertyId)); + verify(dynamoDbAsyncClient).getItem(any(GetItemRequest.class)); + verify(dynamoDbAsyncClient).updateItem(any(UpdateItemRequest.class)); + } + + @Test + public void shouldThrowWhenContractNotFound() { + // Given + String propertyId = "usa/anytown/main-street/999"; + String taskToken = "test-task-token-xyz"; + String eventJson = TestHelpers.createStepFunctionsEvent(taskToken, propertyId); + + // Return empty item map to simulate contract not found + when(dynamoDbAsyncClient.getItem(any(GetItemRequest.class))) + .thenReturn(CompletableFuture.completedFuture( + GetItemResponse.builder().item(Collections.emptyMap()).build())); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(eventJson.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When / Then + assertThrows(ContractStatusNotFoundException.class, () -> + handler.handleRequest(inputStream, outputStream, context)); + } + + @Test + public void shouldHandleDynamoDbFailure() { + // Given + String propertyId = "usa/anytown/main-street/123"; + String taskToken = "test-task-token-fail"; + String eventJson = TestHelpers.createStepFunctionsEvent(taskToken, propertyId); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally( + DynamoDbException.builder().message("Service unavailable").build()); + + when(dynamoDbAsyncClient.getItem(any(GetItemRequest.class))) + .thenReturn(failedFuture); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(eventJson.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When / Then + assertThrows(ContractStatusNotFoundException.class, () -> + handler.handleRequest(inputStream, outputStream, context)); + } +} diff --git a/unicorn_approvals/ApprovalsService/src/test/java/approvals/helpers/TestHelpers.java b/unicorn_approvals/ApprovalsService/src/test/java/approvals/helpers/TestHelpers.java new file mode 100644 index 0000000..3b20fa2 --- /dev/null +++ b/unicorn_approvals/ApprovalsService/src/test/java/approvals/helpers/TestHelpers.java @@ -0,0 +1,108 @@ +package approvals.helpers; + +import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; +import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue; +import com.amazonaws.services.lambda.runtime.events.models.dynamodb.StreamRecord; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Test helper utilities for building DynamoDB Stream and Step Functions events + * used by the Approvals service tests. + */ +public final class TestHelpers { + + private TestHelpers() { + // Utility class + } + + /** + * Creates a DynamoDB stream event with NewImage and OldImage containing contract status fields. + * + * @param propertyId the property ID + * @param contractId the contract ID + * @param newStatus the contract status in the NewImage + * @param oldStatus the contract status in the OldImage (nullable) + * @param taskToken the sfn_wait_approved_task_token value (nullable, placed in NewImage) + * @param oldTaskToken the sfn_wait_approved_task_token value for OldImage (nullable) + * @return a DynamodbEvent with one stream record + */ + public static DynamodbEvent createDynamoDbStreamEvent( + String propertyId, + String contractId, + String newStatus, + String oldStatus, + String taskToken, + String oldTaskToken) { + + Map newImage = new HashMap<>(); + newImage.put("property_id", new AttributeValue().withS(propertyId)); + newImage.put("contract_id", new AttributeValue().withS(contractId)); + newImage.put("contract_status", new AttributeValue().withS(newStatus)); + newImage.put("contract_last_modified_on", new AttributeValue().withS("2022-08-25T01:44:02Z")); + if (taskToken != null) { + newImage.put("sfn_wait_approved_task_token", new AttributeValue().withS(taskToken)); + } + + Map oldImage = null; + if (oldStatus != null) { + oldImage = new HashMap<>(); + oldImage.put("property_id", new AttributeValue().withS(propertyId)); + oldImage.put("contract_id", new AttributeValue().withS(contractId)); + oldImage.put("contract_status", new AttributeValue().withS(oldStatus)); + oldImage.put("contract_last_modified_on", new AttributeValue().withS("2022-08-24T15:53:26Z")); + if (oldTaskToken != null) { + oldImage.put("sfn_wait_approved_task_token", new AttributeValue().withS(oldTaskToken)); + } + } + + StreamRecord streamRecord = new StreamRecord(); + streamRecord.setNewImage(newImage); + streamRecord.setOldImage(oldImage); + streamRecord.setSequenceNumber("123456789"); + + DynamodbEvent.DynamodbStreamRecord record = new DynamodbEvent.DynamodbStreamRecord(); + record.setDynamodb(streamRecord); + record.setEventName("MODIFY"); + + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(Collections.singletonList(record)); + return event; + } + + /** + * Creates a DynamoDB stream event with a null NewImage (for deletion scenarios). + * + * @return a DynamodbEvent with one stream record whose NewImage is null + */ + public static DynamodbEvent createDynamoDbStreamEventWithNullNewImage() { + StreamRecord streamRecord = new StreamRecord(); + streamRecord.setNewImage(null); + streamRecord.setOldImage(null); + streamRecord.setSequenceNumber("123456789"); + + DynamodbEvent.DynamodbStreamRecord record = new DynamodbEvent.DynamodbStreamRecord(); + record.setDynamodb(streamRecord); + record.setEventName("REMOVE"); + + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(Collections.singletonList(record)); + return event; + } + + /** + * Creates a Step Functions event JSON string with TaskToken and Input.property_id. + * + * @param taskToken the Step Functions task token + * @param propertyId the property ID + * @return a JSON string representing the Step Functions callback event + */ + public static String createStepFunctionsEvent(String taskToken, String propertyId) { + return String.format( + "{\"TaskToken\":\"%s\",\"Input\":{\"property_id\":\"%s\"}}", + taskToken, propertyId + ); + } +} diff --git a/unicorn_contracts/ContractsService/pom.xml b/unicorn_contracts/ContractsService/pom.xml index c98f02f..8c27165 100644 --- a/unicorn_contracts/ContractsService/pom.xml +++ b/unicorn_contracts/ContractsService/pom.xml @@ -14,7 +14,8 @@ 2.9.0 3.16.1 5.23.0 - 4.13.2 + 5.12.2 + 5.23.0 1.1.2 @@ -126,9 +127,15 @@ test - junit - junit - ${junit.version} + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} test @@ -155,7 +162,7 @@ org.apache.maven.surefire - surefire-junit4 + surefire-junit-platform 3.5.5 @@ -227,6 +234,50 @@ 21 + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + contracts/utils/** + + + + + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + \ No newline at end of file diff --git a/unicorn_contracts/ContractsService/src/main/java/contracts/ContractEventHandler.java b/unicorn_contracts/ContractsService/src/main/java/contracts/ContractEventHandlerFunction.java similarity index 90% rename from unicorn_contracts/ContractsService/src/main/java/contracts/ContractEventHandler.java rename to unicorn_contracts/ContractsService/src/main/java/contracts/ContractEventHandlerFunction.java index 4de4200..7e26d13 100644 --- a/unicorn_contracts/ContractsService/src/main/java/contracts/ContractEventHandler.java +++ b/unicorn_contracts/ContractsService/src/main/java/contracts/ContractEventHandlerFunction.java @@ -13,28 +13,31 @@ import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.*; import software.amazon.lambda.powertools.logging.Logging; -import software.amazon.lambda.powertools.tracing.Tracing; import software.amazon.lambda.powertools.metrics.FlushMetrics; +import software.amazon.lambda.powertools.metrics.Metrics; +import software.amazon.lambda.powertools.metrics.MetricsFactory; +import software.amazon.lambda.powertools.metrics.model.MetricUnit; +import software.amazon.lambda.powertools.tracing.Tracing; import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.UUID; -public class ContractEventHandler implements RequestHandler { +public class ContractEventHandlerFunction implements RequestHandler { private static final String DDB_TABLE = System.getenv("DYNAMODB_TABLE"); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final Logger LOGGER = LogManager.getLogger(ContractEventHandler.class); + private static final Logger LOGGER = LogManager.getLogger(ContractEventHandlerFunction.class); private static final String HTTP_METHOD_ATTR = "HttpMethod"; private final DynamoDbClient dynamodbClient; - public ContractEventHandler() { + public ContractEventHandlerFunction() { this(DynamoDbClient.builder().build()); } - public ContractEventHandler(DynamoDbClient dynamodbClient) { + public ContractEventHandlerFunction(DynamoDbClient dynamodbClient) { this.dynamodbClient = dynamodbClient; } @@ -56,11 +59,11 @@ public Void handleRequest(SQSEvent event, Context context) { private void processMessage(SQSMessage msg) { LOGGER.debug("Processing message: {}", msg.getMessageId()); - + try { String httpMethod = extractHttpMethod(msg); String body = msg.getBody(); - + if (body == null || body.trim().isEmpty()) { LOGGER.warn("Empty message body for message: {}", msg.getMessageId()); return; @@ -95,15 +98,15 @@ private String extractHttpMethod(SQSMessage msg) { private void createContract(String contractJson) throws JsonProcessingException { Contract contract = OBJECT_MAPPER.readValue(contractJson, Contract.class); validateContract(contract); - + String contractId = UUID.randomUUID().toString(); - long timestamp = Instant.now().toEpochMilli(); + String timestamp = Instant.now().toString(); Map item = Map.of( "property_id", AttributeValue.builder().s(contract.getPropertyId()).build(), "seller_name", AttributeValue.builder().s(contract.getSellerName()).build(), - "contract_created", AttributeValue.builder().n(String.valueOf(timestamp)).build(), - "contract_last_modified_on", AttributeValue.builder().n(String.valueOf(timestamp)).build(), + "contract_created", AttributeValue.builder().s(timestamp).build(), + "contract_last_modified_on", AttributeValue.builder().s(timestamp).build(), "contract_id", AttributeValue.builder().s(contractId).build(), "contract_status", AttributeValue.builder().s(ContractStatusEnum.DRAFT.name()).build(), "address", AttributeValue.builder().m(buildAddressMap(contract.getAddress())).build() @@ -124,6 +127,7 @@ private void createContract(String contractJson) throws JsonProcessingException try { dynamodbClient.putItem(request); + MetricsFactory.getMetricsInstance().addMetric("ContractCreated", 1, MetricUnit.COUNT); } catch (ConditionalCheckFailedException e) { LOGGER.error("Active contract already exists for property: {}", contract.getPropertyId()); throw new IllegalStateException("Contract already exists for property: " + contract.getPropertyId(), e); @@ -134,7 +138,7 @@ private void createContract(String contractJson) throws JsonProcessingException private void updateContract(String contractJson) throws JsonProcessingException { Contract contract = OBJECT_MAPPER.readValue(contractJson, Contract.class); validateContractForUpdate(contract); - + LOGGER.info("Updating contract for Property ID: {}", contract.getPropertyId()); Map key = Map.of( @@ -144,7 +148,7 @@ private void updateContract(String contractJson) throws JsonProcessingException Map expressionValues = Map.of( ":draft", AttributeValue.builder().s(ContractStatusEnum.DRAFT.name()).build(), ":approved", AttributeValue.builder().s(ContractStatusEnum.APPROVED.name()).build(), - ":modifiedDate", AttributeValue.builder().n(String.valueOf(Instant.now().toEpochMilli())).build() + ":modifiedDate", AttributeValue.builder().s(Instant.now().toString()).build() ); UpdateItemRequest request = UpdateItemRequest.builder() @@ -157,6 +161,7 @@ private void updateContract(String contractJson) throws JsonProcessingException try { dynamodbClient.updateItem(request); + MetricsFactory.getMetricsInstance().addMetric("ContractUpdated", 1, MetricUnit.COUNT); } catch (ConditionalCheckFailedException e) { LOGGER.error("Contract not in DRAFT status for property: {}", contract.getPropertyId()); throw new IllegalStateException("Contract not in valid state for update: " + contract.getPropertyId(), e); diff --git a/unicorn_contracts/ContractsService/src/main/java/contracts/utils/Contract.java b/unicorn_contracts/ContractsService/src/main/java/contracts/utils/Contract.java index 14069c3..379af4d 100644 --- a/unicorn_contracts/ContractsService/src/main/java/contracts/utils/Contract.java +++ b/unicorn_contracts/ContractsService/src/main/java/contracts/utils/Contract.java @@ -28,11 +28,11 @@ public class Contract { @JsonProperty("contract_created") @JsonAlias("contract_created") - private Long contractCreated; - + private String contractCreated; + @JsonProperty("contract_last_modified_on") @JsonAlias("contract_last_modified_on") - private Long contractLastModifiedOn; + private String contractLastModifiedOn; public Contract() {} @@ -76,19 +76,19 @@ public void setContractStatus(ContractStatusEnum contractStatus) { this.contractStatus = contractStatus; } - public Long getContractCreated() { + public String getContractCreated() { return contractCreated; } - public void setContractCreated(Long contractCreated) { + public void setContractCreated(String contractCreated) { this.contractCreated = contractCreated; } - public Long getContractLastModifiedOn() { + public String getContractLastModifiedOn() { return contractLastModifiedOn; } - public void setContractLastModifiedOn(Long contractLastModifiedOn) { + public void setContractLastModifiedOn(String contractLastModifiedOn) { this.contractLastModifiedOn = contractLastModifiedOn; } diff --git a/unicorn_contracts/ContractsService/src/main/java/contracts/utils/ResponseParser.java b/unicorn_contracts/ContractsService/src/main/java/contracts/utils/ResponseParser.java index a17201a..d40abe8 100644 --- a/unicorn_contracts/ContractsService/src/main/java/contracts/utils/ResponseParser.java +++ b/unicorn_contracts/ContractsService/src/main/java/contracts/utils/ResponseParser.java @@ -33,7 +33,6 @@ public Contract parseResponse(Map queryResponse) throws // Parse other fields Optional.ofNullable(queryResponse.get("contract_created")) .map(AttributeValue::s) - .map(Long::valueOf) .ifPresent(contract::setContractCreated); Optional.ofNullable(queryResponse.get("contract_id")) @@ -42,7 +41,6 @@ public Contract parseResponse(Map queryResponse) throws Optional.ofNullable(queryResponse.get("contract_last_modified_on")) .map(AttributeValue::s) - .map(Long::valueOf) .ifPresent(contract::setContractLastModifiedOn); Optional.ofNullable(queryResponse.get("contract_status")) diff --git a/unicorn_contracts/ContractsService/src/test/events/create_contract_valid_1.json b/unicorn_contracts/ContractsService/src/test/events/create_contract_valid_1.json new file mode 100644 index 0000000..0d8ca64 --- /dev/null +++ b/unicorn_contracts/ContractsService/src/test/events/create_contract_valid_1.json @@ -0,0 +1,10 @@ +{ + "address": { + "country": "USA", + "city": "Anytown", + "street": "Main Street", + "number": 111 + }, + "seller_name": "John Doe", + "property_id": "usa/anytown/main-street/111" +} diff --git a/unicorn_contracts/ContractsService/src/test/events/create_empty_dict_body_event.json b/unicorn_contracts/ContractsService/src/test/events/create_empty_dict_body_event.json deleted file mode 100644 index ba57534..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/create_empty_dict_body_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "POST" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/create_missing_body_event.json b/unicorn_contracts/ContractsService/src/test/events/create_missing_body_event.json deleted file mode 100644 index e331ffd..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/create_missing_body_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{'hello':'world'}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "POST" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/create_valid_event.json b/unicorn_contracts/ContractsService/src/test/events/create_valid_event.json deleted file mode 100644 index 5629b34..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/create_valid_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{ \"address\": { \"country\": \"USA\", \"city\": \"Anytown\", \"street\": \"Main Street\", \"number\": 123 }, \"seller_name\": \"John Smith\", \"property_id\": \"usa/anytown/main-street/123\"}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "POST" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/create_wrong_event.json b/unicorn_contracts/ContractsService/src/test/events/create_wrong_event.json deleted file mode 100644 index c9a9b91..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/create_wrong_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{\n \"add\": \"St.1 , Building 10\",\n \"seller\": \"John Smith\",\n \"property\": \"4781231c-bc30-4f30-8b30-7145f4dd1adb\"\n}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "POST" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/update_contract_valid_1.json b/unicorn_contracts/ContractsService/src/test/events/update_contract_valid_1.json new file mode 100644 index 0000000..611e693 --- /dev/null +++ b/unicorn_contracts/ContractsService/src/test/events/update_contract_valid_1.json @@ -0,0 +1,3 @@ +{ + "property_id": "usa/anytown/main-street/111" +} diff --git a/unicorn_contracts/ContractsService/src/test/events/update_empty_dict_body_event.json b/unicorn_contracts/ContractsService/src/test/events/update_empty_dict_body_event.json deleted file mode 100644 index abd64d9..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/update_empty_dict_body_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "PUT" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/update_missing_body_event.json b/unicorn_contracts/ContractsService/src/test/events/update_missing_body_event.json deleted file mode 100644 index 668fbf7..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/update_missing_body_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "PUT" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/update_valid_event.json b/unicorn_contracts/ContractsService/src/test/events/update_valid_event.json deleted file mode 100644 index ab7778b..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/update_valid_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{\n \"property_id\": \"usa/anytown/main-street/123\"}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "PUT" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/events/update_wrong_event.json b/unicorn_contracts/ContractsService/src/test/events/update_wrong_event.json deleted file mode 100644 index 89ca332..0000000 --- a/unicorn_contracts/ContractsService/src/test/events/update_wrong_event.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "{\n \"add\": \"St.1 , Building 10\",\n \"sell\": \"John Smith\",\n \"prop\": \"4781231c-bc30-4f30-8b30-7145f4dd1adb\",\n \"cont\": \"8155fdc5-ba1d-4e51-bcbf-7b417c01a4f3\"}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDACKCEVSQ6C2EXAMPLE", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "HttpMethod": { - "Type": "String", - "Value": "PUT" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-west-2:123456789012:my-queue", - "awsRegion": "us-west-2" - } - ] -} diff --git a/unicorn_contracts/ContractsService/src/test/java/contracts/CreateContractTests.java b/unicorn_contracts/ContractsService/src/test/java/contracts/CreateContractTests.java index aec7b53..8592f41 100644 --- a/unicorn_contracts/ContractsService/src/test/java/contracts/CreateContractTests.java +++ b/unicorn_contracts/ContractsService/src/test/java/contracts/CreateContractTests.java @@ -2,45 +2,46 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.events.SQSEvent; -import com.amazonaws.services.lambda.runtime.events.SQSEvent.MessageAttribute; -import com.amazonaws.services.lambda.runtime.events.SQSEvent.SQSMessage; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import contracts.helpers.TestHelpers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; import software.amazon.awssdk.services.dynamodb.model.PutItemResponse; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse; -import java.util.Collections; -import java.util.Map; - +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class CreateContractTests { @Mock private Context context; - + @Mock private DynamoDbClient dynamoDbClient; - - private ContractEventHandler handler; - @Before + private ContractEventHandlerFunction handler; + + @BeforeEach public void setUp() { - handler = new ContractEventHandler(dynamoDbClient); + handler = new ContractEventHandlerFunction(dynamoDbClient); } @Test public void shouldProcessValidCreateEvent() { // Given - SQSEvent event = createTestEvent("POST", - "{ \"address\": { \"country\": \"USA\", \"city\": \"Anytown\", \"street\": \"Main Street\", \"number\": 123 }, \"seller_name\": \"John Smith\", \"property_id\": \"usa/anytown/main-street/123\"}"); - + String payload = TestHelpers.loadEvent("create_contract_valid_1"); + SQSEvent event = TestHelpers.createSqsEvent("POST", payload); + when(dynamoDbClient.putItem(any(PutItemRequest.class))) .thenReturn(PutItemResponse.builder().build()); @@ -52,58 +53,71 @@ public void shouldProcessValidCreateEvent() { } @Test - public void shouldHandleNullEvent() { + public void shouldProcessValidUpdateEvent() { + // Given + String payload = TestHelpers.loadEvent("update_contract_valid_1"); + SQSEvent event = TestHelpers.createSqsEvent("PUT", payload); + + when(dynamoDbClient.updateItem(any(UpdateItemRequest.class))) + .thenReturn(UpdateItemResponse.builder().build()); + // When - handler.handleRequest(null, context); - + handler.handleRequest(event, context); + // Then - verifyNoInteractions(dynamoDbClient); + verify(dynamoDbClient, times(1)).updateItem(any(UpdateItemRequest.class)); + verify(dynamoDbClient, never()).putItem(any(PutItemRequest.class)); } @Test - public void shouldHandleEmptyEvent() { + public void shouldHandleConditionalCheckFailedOnCreate() { // Given - SQSEvent emptyEvent = new SQSEvent(); - - // When - handler.handleRequest(emptyEvent, context); - - // Then - verifyNoInteractions(dynamoDbClient); + String payload = TestHelpers.loadEvent("create_contract_valid_1"); + SQSEvent event = TestHelpers.createSqsEvent("POST", payload); + + when(dynamoDbClient.putItem(any(PutItemRequest.class))) + .thenThrow(ConditionalCheckFailedException.builder() + .message("Active contract already exists").build()); + + // When / Then + assertThrows(RuntimeException.class, () -> handler.handleRequest(event, context)); + verify(dynamoDbClient, times(1)).putItem(any(PutItemRequest.class)); } - @Test(expected = RuntimeException.class) - public void shouldThrowExceptionForMissingHttpMethod() { + @Test + public void shouldHandleConditionalCheckFailedOnUpdate() { // Given - SQSEvent event = createTestEventWithoutHttpMethod( - "{ \"address\": { \"country\": \"USA\", \"city\": \"Anytown\", \"street\": \"Main Street\", \"number\": 123 }, \"seller_name\": \"John Smith\", \"property_id\": \"usa/anytown/main-street/123\"}"); - - // When - handler.handleRequest(event, context); + String payload = TestHelpers.loadEvent("update_contract_valid_1"); + SQSEvent event = TestHelpers.createSqsEvent("PUT", payload); + + when(dynamoDbClient.updateItem(any(UpdateItemRequest.class))) + .thenThrow(ConditionalCheckFailedException.builder() + .message("Contract not in DRAFT status").build()); + + // When / Then + assertThrows(RuntimeException.class, () -> handler.handleRequest(event, context)); + verify(dynamoDbClient, times(1)).updateItem(any(UpdateItemRequest.class)); } - private SQSEvent createTestEvent(String httpMethod, String body) { - SQSEvent event = new SQSEvent(); - SQSMessage message = new SQSMessage(); - message.setMessageId("test-message-id"); - message.setBody(body); - - MessageAttribute httpMethodAttr = new MessageAttribute(); - httpMethodAttr.setStringValue(httpMethod); - message.setMessageAttributes(Map.of("HttpMethod", httpMethodAttr)); - - event.setRecords(Collections.singletonList(message)); - return event; + @Test + public void shouldHandleMalformedJsonBody() { + // Given + SQSEvent event = TestHelpers.createSqsEvent("POST", "{ this is not valid json }"); + + // When / Then + assertThrows(RuntimeException.class, () -> handler.handleRequest(event, context)); + verifyNoInteractions(dynamoDbClient); } - private SQSEvent createTestEventWithoutHttpMethod(String body) { - SQSEvent event = new SQSEvent(); - SQSMessage message = new SQSMessage(); - message.setMessageId("test-message-id"); - message.setBody(body); - message.setMessageAttributes(Collections.emptyMap()); - - event.setRecords(Collections.singletonList(message)); - return event; + @Test + public void shouldIgnoreUnsupportedHttpMethod() { + // Given + String payload = TestHelpers.loadEvent("create_contract_valid_1"); + SQSEvent event = TestHelpers.createSqsEvent("DELETE", payload); + + // When / Then - DELETE is not handled, no DynamoDB interaction + assertDoesNotThrow(() -> handler.handleRequest(event, context)); + verifyNoInteractions(dynamoDbClient); } + } diff --git a/unicorn_contracts/ContractsService/src/test/java/contracts/helpers/TestHelpers.java b/unicorn_contracts/ContractsService/src/test/java/contracts/helpers/TestHelpers.java new file mode 100644 index 0000000..adb9dd8 --- /dev/null +++ b/unicorn_contracts/ContractsService/src/test/java/contracts/helpers/TestHelpers.java @@ -0,0 +1,72 @@ +package contracts.helpers; + +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import com.amazonaws.services.lambda.runtime.events.SQSEvent.MessageAttribute; +import com.amazonaws.services.lambda.runtime.events.SQSEvent.SQSMessage; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +/** + * Test helper utilities for building SQS events used by the Contracts service tests. + */ +public final class TestHelpers { + + private TestHelpers() { + // Utility class + } + + /** + * Loads a JSON event file from src/test/events/ and returns its content as a String. + * + * @param name the file name without extension (e.g. "create_contract_valid_1") + * @return the file content as a String + */ + public static String loadEvent(String name) { + String path = "src/test/events/" + name + ".json"; + try { + return java.nio.file.Files.readString(java.nio.file.Path.of(path)); + } catch (IOException e) { + throw new RuntimeException("Failed to read event file: " + path, e); + } + } + + /** + * Creates an SQS event with an HttpMethod message attribute and a JSON body. + * + * @param httpMethod the HTTP method (e.g. POST, PUT) + * @param body the JSON body of the message + * @return a fully-formed SQSEvent with one record + */ + public static SQSEvent createSqsEvent(String httpMethod, String body) { + SQSEvent event = new SQSEvent(); + SQSMessage message = new SQSMessage(); + message.setMessageId("test-message-id"); + message.setBody(body); + + MessageAttribute httpMethodAttr = new MessageAttribute(); + httpMethodAttr.setStringValue(httpMethod); + message.setMessageAttributes(Map.of("HttpMethod", httpMethodAttr)); + + event.setRecords(Collections.singletonList(message)); + return event; + } + + /** + * Creates an SQS event without the HttpMethod message attribute. + * + * @param body the JSON body of the message + * @return an SQSEvent with one record that has no HttpMethod attribute + */ + public static SQSEvent createSqsEventWithoutHttpMethod(String body) { + SQSEvent event = new SQSEvent(); + SQSMessage message = new SQSMessage(); + message.setMessageId("test-message-id"); + message.setBody(body); + message.setMessageAttributes(Collections.emptyMap()); + + event.setRecords(Collections.singletonList(message)); + return event; + } +} diff --git a/unicorn_web/PublicationManagerService/pom.xml b/unicorn_web/PublicationManagerService/pom.xml index ead96d8..dca02e2 100644 --- a/unicorn_web/PublicationManagerService/pom.xml +++ b/unicorn_web/PublicationManagerService/pom.xml @@ -14,7 +14,8 @@ 2.9.0 3.16.1 5.23.0 - 4.13.2 + 5.12.2 + 5.23.0 1.1.2 1.4.0 2.42.13 @@ -114,9 +115,15 @@ test - junit - junit - ${junit.version} + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} test @@ -249,6 +256,53 @@ 21 + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + schema/** + dao/** + publicationmanager/RequestApproval* + publicationmanager/Address* + + + + + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + \ No newline at end of file diff --git a/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/PublicationEvaluationEventHandler.java b/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/PublicationEvaluationEventHandler.java index 964cb3f..eb63cdc 100644 --- a/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/PublicationEvaluationEventHandler.java +++ b/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/PublicationEvaluationEventHandler.java @@ -22,6 +22,9 @@ import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.lambda.powertools.logging.Logging; import software.amazon.lambda.powertools.metrics.FlushMetrics; +import software.amazon.lambda.powertools.metrics.Metrics; +import software.amazon.lambda.powertools.metrics.MetricsFactory; +import software.amazon.lambda.powertools.metrics.model.MetricUnit; import software.amazon.lambda.powertools.tracing.Tracing; import schema.unicorn_approvals.publicationevaluationcompleted.marshaller.Marshaller; import schema.unicorn_approvals.publicationevaluationcompleted.AWSEvent; @@ -98,6 +101,11 @@ public void handleRequest(InputStream inputStream, OutputStream outputStream, Co private void updatePropertyStatus(String evaluationResult, String propertyId) { logger.info("Updating property status for property ID: {}", propertyId); logger.info("Evaluation result: {}", evaluationResult); + if (!"APPROVED".equalsIgnoreCase(evaluationResult) && !"DECLINED".equalsIgnoreCase(evaluationResult)) { + logger.warn("Unknown evaluation result '{}', skipping DynamoDB update", evaluationResult); + return; + } + try { String[] parts = propertyId.split("/"); if (parts.length != 4) { @@ -124,7 +132,8 @@ private void updatePropertyStatus(String evaluationResult, String propertyId) { logger.info("Updating property {} with status: {}", propertyId, evaluationResult); propertyTable.putItem(existingProperty).join(); - + MetricsFactory.getMetricsInstance().addMetric("PropertiesApproved", 1, MetricUnit.COUNT); + } catch (Exception e) { logger.error("Failed to update property status for ID: {}", propertyId, e); throw new RuntimeException("Property update failed", e); diff --git a/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/RequestApprovalFunction.java b/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/RequestApprovalFunction.java index 83773a1..29551be 100644 --- a/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/RequestApprovalFunction.java +++ b/unicorn_web/PublicationManagerService/src/main/java/publicationmanager/RequestApprovalFunction.java @@ -37,6 +37,9 @@ import software.amazon.awssdk.services.eventbridge.model.PutEventsRequestEntry; import software.amazon.lambda.powertools.logging.Logging; import software.amazon.lambda.powertools.metrics.FlushMetrics; +import software.amazon.lambda.powertools.metrics.Metrics; +import software.amazon.lambda.powertools.metrics.MetricsFactory; +import software.amazon.lambda.powertools.metrics.model.MetricUnit; import software.amazon.lambda.powertools.tracing.Tracing; /** @@ -48,6 +51,8 @@ public class RequestApprovalFunction { private static final Set NO_ACTION_STATUSES = new HashSet<>(Arrays.asList("APPROVED")); private static final String PROPERTY_ID_PATTERN = "[a-z-]+\\/[a-z-]+\\/[a-z][a-z0-9-]*\\/[0-9-]+"; + private static final String SERVICE_NAMESPACE = System.getenv("SERVICE_NAMESPACE"); + private final Pattern propertyIdPattern = Pattern.compile(PROPERTY_ID_PATTERN); private final String tableName = System.getenv("DYNAMODB_TABLE"); private final String eventBus = System.getenv("EVENT_BUS"); @@ -188,19 +193,25 @@ private void sendEvent(Property property) throws JsonProcessingException { RequestApproval event = new RequestApproval(); event.setPropertyId(property.getId()); - + Address address = new Address(); address.setCity(property.getCity()); address.setCountry(property.getCountry()); address.setNumber(property.getPropertyNumber()); event.setAddress(address); + event.setStatus("PENDING"); + event.setListprice(property.getListprice()); + event.setImages(property.getImages()); + event.setDescription(property.getDescription()); + event.setCurrency(property.getCurrency()); + String eventString = objectMapper.writeValueAsString(event); logger.info("Event payload created: {}", eventString); PutEventsRequestEntry requestEntry = PutEventsRequestEntry.builder() .eventBusName(eventBus) - .source("Unicorn.Web") + .source(SERVICE_NAMESPACE) .resources(property.getId()) .detailType("PublicationApprovalRequested") .detail(eventString) @@ -213,9 +224,10 @@ private void sendEvent(Property property) throws JsonProcessingException { logger.debug("Sending event to EventBridge bus: {}", eventBus); try { eventBridgeClient.putEvents(eventsRequest).join(); + MetricsFactory.getMetricsInstance().addMetric("ApprovalsRequested", 1, MetricUnit.COUNT); logger.info("Event sent successfully for property: {}", property.getId()); } catch (Exception e) { - logger.error("Failed to send event to EventBridge for property: {}, bus: {}", + logger.error("Failed to send event to EventBridge for property: {}, bus: {}", property.getId(), eventBus, e); throw e; } @@ -227,6 +239,21 @@ class RequestApproval { String propertyId; Address address; + @JsonProperty("status") + String status; + + @JsonProperty("listprice") + Float listprice; + + @JsonProperty("images") + java.util.List images; + + @JsonProperty("description") + String description; + + @JsonProperty("currency") + String currency; + public String getPropertyId() { return propertyId; } @@ -242,6 +269,46 @@ public Address getAddress() { public void setAddress(Address address) { this.address = address; } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Float getListprice() { + return listprice; + } + + public void setListprice(Float listprice) { + this.listprice = listprice; + } + + public java.util.List getImages() { + return images; + } + + public void setImages(java.util.List images) { + this.images = images; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } } class Address { diff --git a/unicorn_web/PublicationManagerService/src/test/events/publication_evaluation_completed_approved.json b/unicorn_web/PublicationManagerService/src/test/events/publication_evaluation_completed_approved.json new file mode 100644 index 0000000..c2cdbd2 --- /dev/null +++ b/unicorn_web/PublicationManagerService/src/test/events/publication_evaluation_completed_approved.json @@ -0,0 +1,14 @@ +{ + "version": "0", + "id": "f849f683-76e1-1c84-669d-544a9828dfef", + "detail-type": "PublicationEvaluationCompleted", + "source": "unicorn-approvals", + "account": "123456789012", + "time": "2022-08-16T06:33:05Z", + "region": "us-east-1", + "resources": [], + "detail": { + "property_id": "usa/anytown/main-street/123", + "evaluation_result": "APPROVED" + } +} diff --git a/unicorn_web/PublicationManagerService/src/test/events/publication_evaluation_completed_declined.json b/unicorn_web/PublicationManagerService/src/test/events/publication_evaluation_completed_declined.json new file mode 100644 index 0000000..b3a488d --- /dev/null +++ b/unicorn_web/PublicationManagerService/src/test/events/publication_evaluation_completed_declined.json @@ -0,0 +1,14 @@ +{ + "version": "0", + "id": "f849f683-76e1-1c84-669d-544a9828dfef", + "detail-type": "PublicationEvaluationCompleted", + "source": "unicorn-approvals", + "account": "123456789012", + "time": "2022-08-16T06:33:05Z", + "region": "us-east-1", + "resources": [], + "detail": { + "property_id": "usa/anytown/main-street/123", + "evaluation_result": "DECLINED" + } +} diff --git a/unicorn_web/PublicationManagerService/src/test/events/request_approval_event.json b/unicorn_web/PublicationManagerService/src/test/events/request_approval_event.json new file mode 100644 index 0000000..321e5cd --- /dev/null +++ b/unicorn_web/PublicationManagerService/src/test/events/request_approval_event.json @@ -0,0 +1,10 @@ +{ + "Records": [ + { + "messageId": "test-message-001", + "body": "{\"property_id\":\"usa/anytown/main-street/123\"}", + "eventSource": "aws:sqs", + "awsRegion": "us-east-1" + } + ] +} diff --git a/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/PublicationEvaluationEventHandlerTests.java b/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/PublicationEvaluationEventHandlerTests.java new file mode 100644 index 0000000..1a584e5 --- /dev/null +++ b/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/PublicationEvaluationEventHandlerTests.java @@ -0,0 +1,179 @@ +package publicationmanager; + +import com.amazonaws.services.lambda.runtime.Context; +import dao.Property; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class PublicationEvaluationEventHandlerTests { + + @Mock + private Context context; + + @Mock + private DynamoDbAsyncTable propertyTable; + + private PublicationEvaluationEventHandler handler; + + private static final String APPROVED_EVENT = "{\n" + + " \"version\": \"0\",\n" + + " \"id\": \"test-id\",\n" + + " \"detail-type\": \"PublicationEvaluationCompleted\",\n" + + " \"source\": \"unicorn-approvals\",\n" + + " \"account\": \"123456789012\",\n" + + " \"time\": \"2022-08-16T06:33:05Z\",\n" + + " \"region\": \"us-east-1\",\n" + + " \"resources\": [],\n" + + " \"detail\": {\n" + + " \"property_id\": \"usa/anytown/main-street/123\",\n" + + " \"evaluation_result\": \"APPROVED\"\n" + + " }\n" + + "}"; + + private static final String DECLINED_EVENT = "{\n" + + " \"version\": \"0\",\n" + + " \"id\": \"test-id\",\n" + + " \"detail-type\": \"PublicationEvaluationCompleted\",\n" + + " \"source\": \"unicorn-approvals\",\n" + + " \"account\": \"123456789012\",\n" + + " \"time\": \"2022-08-16T06:33:05Z\",\n" + + " \"region\": \"us-east-1\",\n" + + " \"resources\": [],\n" + + " \"detail\": {\n" + + " \"property_id\": \"usa/anytown/main-street/123\",\n" + + " \"evaluation_result\": \"DECLINED\"\n" + + " }\n" + + "}"; + + private static final String UNKNOWN_RESULT_EVENT = "{\n" + + " \"version\": \"0\",\n" + + " \"id\": \"test-id\",\n" + + " \"detail-type\": \"PublicationEvaluationCompleted\",\n" + + " \"source\": \"unicorn-approvals\",\n" + + " \"account\": \"123456789012\",\n" + + " \"time\": \"2022-08-16T06:33:05Z\",\n" + + " \"region\": \"us-east-1\",\n" + + " \"resources\": [],\n" + + " \"detail\": {\n" + + " \"property_id\": \"usa/anytown/main-street/123\",\n" + + " \"evaluation_result\": \"UNKNOWN\"\n" + + " }\n" + + "}"; + + private static final String INVALID_PROPERTY_ID_EVENT = "{\n" + + " \"version\": \"0\",\n" + + " \"id\": \"test-id\",\n" + + " \"detail-type\": \"PublicationEvaluationCompleted\",\n" + + " \"source\": \"unicorn-approvals\",\n" + + " \"account\": \"123456789012\",\n" + + " \"time\": \"2022-08-16T06:33:05Z\",\n" + + " \"region\": \"us-east-1\",\n" + + " \"resources\": [],\n" + + " \"detail\": {\n" + + " \"property_id\": \"invalid-id\",\n" + + " \"evaluation_result\": \"APPROVED\"\n" + + " }\n" + + "}"; + + @BeforeEach + public void setUp() throws Exception { + handler = new PublicationEvaluationEventHandler(); + Field tableField = PublicationEvaluationEventHandler.class.getDeclaredField("propertyTable"); + tableField.setAccessible(true); + tableField.set(handler, propertyTable); + } + + @Test + public void shouldUpdateStatusToApproved() throws Exception { + // Given + Property existingProperty = new Property(); + existingProperty.setCountry("usa"); + existingProperty.setCity("anytown"); + existingProperty.setStreet("main-street"); + existingProperty.setPropertyNumber("123"); + existingProperty.setStatus("PENDING"); + + when(propertyTable.getItem(any(Key.class))) + .thenReturn(CompletableFuture.completedFuture(existingProperty)); + when(propertyTable.putItem(any(Property.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(APPROVED_EVENT.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When + handler.handleRequest(inputStream, outputStream, context); + + // Then + verify(propertyTable).getItem(any(Key.class)); + verify(propertyTable).putItem(any(Property.class)); + String response = outputStream.toString(StandardCharsets.UTF_8); + assertTrue(response.contains("Successfully updated")); + } + + @Test + public void shouldUpdateStatusToDeclined() throws Exception { + // Given + Property existingProperty = new Property(); + existingProperty.setCountry("usa"); + existingProperty.setCity("anytown"); + existingProperty.setStreet("main-street"); + existingProperty.setPropertyNumber("123"); + existingProperty.setStatus("PENDING"); + + when(propertyTable.getItem(any(Key.class))) + .thenReturn(CompletableFuture.completedFuture(existingProperty)); + when(propertyTable.putItem(any(Property.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(DECLINED_EVENT.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When + handler.handleRequest(inputStream, outputStream, context); + + // Then + verify(propertyTable).getItem(any(Key.class)); + verify(propertyTable).putItem(any(Property.class)); + } + + @Test + public void shouldNotUpdateForUnknownResult() { + // Given + ByteArrayInputStream inputStream = new ByteArrayInputStream(UNKNOWN_RESULT_EVENT.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When / Then - the handler returns success response since it just skips the update + assertDoesNotThrow(() -> handler.handleRequest(inputStream, outputStream, context)); + + // No DynamoDB operations should occur for unknown evaluation result + verifyNoInteractions(propertyTable); + } + + @Test + public void shouldHandleInvalidPropertyId() { + // Given - property_id "invalid-id" does not have 4 parts when split by "/" + ByteArrayInputStream inputStream = new ByteArrayInputStream(INVALID_PROPERTY_ID_EVENT.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // When / Then - should throw because property ID format is invalid (not 4 parts) + assertThrows(RuntimeException.class, () -> + handler.handleRequest(inputStream, outputStream, context)); + } +} diff --git a/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/RequestApprovalFunctionTests.java b/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/RequestApprovalFunctionTests.java new file mode 100644 index 0000000..d1ec7d8 --- /dev/null +++ b/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/RequestApprovalFunctionTests.java @@ -0,0 +1,170 @@ +package publicationmanager; + +import publicationmanager.helpers.TestHelpers; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import dao.Property; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.model.PagePublisher; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.services.eventbridge.EventBridgeAsyncClient; +import software.amazon.awssdk.services.eventbridge.model.PutEventsRequest; +import software.amazon.awssdk.services.eventbridge.model.PutEventsResponse; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class RequestApprovalFunctionTests { + + @Mock + private Context context; + + @Mock + private DynamoDbAsyncTable propertyTable; + + @Mock + private EventBridgeAsyncClient eventBridgeClient; + + @SuppressWarnings("unchecked") + @Mock + private PagePublisher mockPagePublisher; + + @Mock + private SdkPublisher mockItemsPublisher; + + private RequestApprovalFunction handler; + + @BeforeEach + public void setUp() throws Exception { + handler = new RequestApprovalFunction(); + // Inject mock table and EventBridge client via reflection + Field tableField = RequestApprovalFunction.class.getDeclaredField("propertyTable"); + tableField.setAccessible(true); + tableField.set(handler, propertyTable); + + Field ebField = RequestApprovalFunction.class.getDeclaredField("eventBridgeClient"); + ebField.setAccessible(true); + ebField.set(handler, eventBridgeClient); + } + + private void stubQueryReturning(Property... properties) { + when(propertyTable.query(any(QueryEnhancedRequest.class))).thenReturn(mockPagePublisher); + when(mockPagePublisher.items()).thenReturn(mockItemsPublisher); + when(mockItemsPublisher.subscribe(any(Consumer.class))).thenAnswer(invocation -> { + Consumer consumer = invocation.getArgument(0); + for (Property p : properties) { + consumer.accept(p); + } + return CompletableFuture.completedFuture(null); + }); + } + + private Property createTestProperty(String status) { + Property property = new Property(); + property.setCountry("usa"); + property.setCity("anytown"); + property.setStreet("main-street"); + property.setPropertyNumber("123"); + property.setStatus(status); + property.setDescription("A nice house"); + property.setCurrency("USD"); + property.setListprice(200000f); + property.setImages(List.of("image1.jpg")); + return property; + } + + @Test + public void shouldQueryPropertyAndPublishEvent() { + // Given + Property property = createTestProperty("PENDING"); + stubQueryReturning(property); + + SQSEvent event = TestHelpers.createSqsEvent("{\"property_id\":\"usa/anytown/main-street/123\"}"); + + when(eventBridgeClient.putEvents(any(PutEventsRequest.class))) + .thenReturn(CompletableFuture.completedFuture(PutEventsResponse.builder().build())); + + // When + handler.handleRequest(event, context); + + // Then + verify(propertyTable).query(any(QueryEnhancedRequest.class)); + verify(eventBridgeClient).putEvents(any(PutEventsRequest.class)); + } + + @Test + public void shouldSkipInvalidPropertyIdFormat() { + // Given - property_id does not match the expected pattern + SQSEvent event = TestHelpers.createSqsEvent("{\"property_id\":\"invalid-format\"}"); + + // When + handler.handleRequest(event, context); + + // Then - no DynamoDB query or EventBridge call should be made + verifyNoInteractions(propertyTable); + verifyNoInteractions(eventBridgeClient); + } + + @Test + public void shouldSkipAlreadyApprovedProperty() { + // Given + Property property = createTestProperty("APPROVED"); + stubQueryReturning(property); + + SQSEvent event = TestHelpers.createSqsEvent("{\"property_id\":\"usa/anytown/main-street/123\"}"); + + // When + handler.handleRequest(event, context); + + // Then - property is APPROVED so no event should be sent + verify(propertyTable).query(any(QueryEnhancedRequest.class)); + verifyNoInteractions(eventBridgeClient); + } + + @Test + public void shouldHandlePropertyNotFound() { + // Given + stubQueryReturning(); // empty result + + SQSEvent event = TestHelpers.createSqsEvent("{\"property_id\":\"usa/anytown/main-street/999\"}"); + + // When + handler.handleRequest(event, context); + + // Then - no event should be published + verify(propertyTable).query(any(QueryEnhancedRequest.class)); + verifyNoInteractions(eventBridgeClient); + } + + @Test + public void shouldHandleEventBridgeFailure() { + // Given + Property property = createTestProperty("PENDING"); + stubQueryReturning(property); + + SQSEvent event = TestHelpers.createSqsEvent("{\"property_id\":\"usa/anytown/main-street/123\"}"); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("EventBridge unavailable")); + + when(eventBridgeClient.putEvents(any(PutEventsRequest.class))) + .thenReturn(failedFuture); + + // When / Then - the handler catches the exception via the outer try/catch + assertDoesNotThrow(() -> handler.handleRequest(event, context)); + verify(eventBridgeClient).putEvents(any(PutEventsRequest.class)); + } +} diff --git a/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/helpers/TestHelpers.java b/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/helpers/TestHelpers.java new file mode 100644 index 0000000..624725d --- /dev/null +++ b/unicorn_web/PublicationManagerService/src/test/java/publicationmanager/helpers/TestHelpers.java @@ -0,0 +1,53 @@ +package publicationmanager.helpers; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import com.amazonaws.services.lambda.runtime.events.SQSEvent.SQSMessage; + +import java.util.Collections; +import java.util.Map; + +/** + * Test helper utilities for building API Gateway proxy request events and SQS events + * used by the Publication Manager service tests. + */ +public final class TestHelpers { + + private TestHelpers() { + // Utility class + } + + /** + * Creates an API Gateway proxy request event. + * + * @param httpMethod the HTTP method (e.g. GET, POST) + * @param resource the API Gateway resource path template + * @param pathParameters the path parameters map + * @return an APIGatewayProxyRequestEvent + */ + public static APIGatewayProxyRequestEvent createApiGatewayEvent( + String httpMethod, + String resource, + Map pathParameters) { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setHttpMethod(httpMethod); + event.setResource(resource); + event.setPathParameters(pathParameters); + return event; + } + + /** + * Creates an SQS event with a single message body. + * + * @param body the JSON body of the SQS message + * @return an SQSEvent with one record + */ + public static SQSEvent createSqsEvent(String body) { + SQSEvent event = new SQSEvent(); + SQSMessage message = new SQSMessage(); + message.setMessageId("test-message-id"); + message.setBody(body); + event.setRecords(Collections.singletonList(message)); + return event; + } +} diff --git a/unicorn_web/SearchService/pom.xml b/unicorn_web/SearchService/pom.xml index 9401c29..ac99f9b 100644 --- a/unicorn_web/SearchService/pom.xml +++ b/unicorn_web/SearchService/pom.xml @@ -14,7 +14,8 @@ 2.9.0 3.16.1 5.23.0 - 4.13.2 + 5.12.2 + 5.23.0 1.1.2 1.4.0 2.42.13 @@ -115,9 +116,15 @@ test - junit - junit - ${junit.version} + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} test @@ -250,6 +257,50 @@ 21 + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + dao/** + + + + + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + \ No newline at end of file diff --git a/unicorn_web/SearchService/src/test/events/get_property_details.json b/unicorn_web/SearchService/src/test/events/get_property_details.json new file mode 100644 index 0000000..993d683 --- /dev/null +++ b/unicorn_web/SearchService/src/test/events/get_property_details.json @@ -0,0 +1,10 @@ +{ + "httpMethod": "GET", + "resource": "/properties/{country}/{city}/{street}/{number}", + "pathParameters": { + "country": "usa", + "city": "anytown", + "street": "main-street", + "number": "123" + } +} diff --git a/unicorn_web/SearchService/src/test/events/search_by_city.json b/unicorn_web/SearchService/src/test/events/search_by_city.json new file mode 100644 index 0000000..e23c8f1 --- /dev/null +++ b/unicorn_web/SearchService/src/test/events/search_by_city.json @@ -0,0 +1,8 @@ +{ + "httpMethod": "GET", + "resource": "/search/{country}/{city}", + "pathParameters": { + "country": "usa", + "city": "anytown" + } +} diff --git a/unicorn_web/SearchService/src/test/events/search_by_city_and_street.json b/unicorn_web/SearchService/src/test/events/search_by_city_and_street.json new file mode 100644 index 0000000..301ce1b --- /dev/null +++ b/unicorn_web/SearchService/src/test/events/search_by_city_and_street.json @@ -0,0 +1,9 @@ +{ + "httpMethod": "GET", + "resource": "/search/{country}/{city}/{street}", + "pathParameters": { + "country": "usa", + "city": "anytown", + "street": "main-street" + } +} diff --git a/unicorn_web/SearchService/src/test/java/search/PropertySearchFunctionTests.java b/unicorn_web/SearchService/src/test/java/search/PropertySearchFunctionTests.java new file mode 100644 index 0000000..81a1637 --- /dev/null +++ b/unicorn_web/SearchService/src/test/java/search/PropertySearchFunctionTests.java @@ -0,0 +1,198 @@ +package search; + +import search.helpers.TestHelpers; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import dao.Property; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.model.PagePublisher; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class PropertySearchFunctionTests { + + @Mock + private Context context; + + @Mock + private DynamoDbAsyncTable propertyTable; + + @SuppressWarnings("unchecked") + @Mock + private PagePublisher mockPagePublisher; + + @Mock + private SdkPublisher mockItemsPublisher; + + private PropertySearchFunction handler; + + @BeforeEach + public void setUp() throws Exception { + handler = new PropertySearchFunction(); + Field tableField = PropertySearchFunction.class.getDeclaredField("propertyTable"); + tableField.setAccessible(true); + tableField.set(handler, propertyTable); + } + + private void stubQueryReturning(Property... properties) { + when(propertyTable.query(any(QueryEnhancedRequest.class))).thenReturn(mockPagePublisher); + when(mockPagePublisher.items()).thenReturn(mockItemsPublisher); + when(mockItemsPublisher.subscribe(any(Consumer.class))).thenAnswer(invocation -> { + Consumer consumer = invocation.getArgument(0); + for (Property p : properties) { + consumer.accept(p); + } + return CompletableFuture.completedFuture(null); + }); + } + + private Property createTestProperty(String status) { + Property property = new Property(); + property.setCountry("usa"); + property.setCity("anytown"); + property.setStreet("main-street"); + property.setPropertyNumber("123"); + property.setStatus(status); + property.setDescription("A beautiful property"); + property.setCurrency("USD"); + property.setListprice(250000f); + return property; + } + + @Test + public void shouldSearchByCity() { + // Given + Property property = createTestProperty("APPROVED"); + stubQueryReturning(property); + + APIGatewayProxyRequestEvent event = TestHelpers.createApiGatewayEvent( + "GET", + "/search/{country}/{city}", + Map.of("country", "usa", "city", "anytown") + ); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(event, context); + + // Then + assertEquals(200, response.getStatusCode()); + assertTrue(response.getBody().contains("anytown")); + verify(propertyTable).query(any(QueryEnhancedRequest.class)); + } + + @Test + public void shouldSearchByCityAndStreet() { + // Given + Property property = createTestProperty("APPROVED"); + stubQueryReturning(property); + + APIGatewayProxyRequestEvent event = TestHelpers.createApiGatewayEvent( + "GET", + "/search/{country}/{city}/{street}", + Map.of("country", "usa", "city", "anytown", "street", "main-street") + ); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(event, context); + + // Then + assertEquals(200, response.getStatusCode()); + assertTrue(response.getBody().contains("main-street")); + verify(propertyTable).query(any(QueryEnhancedRequest.class)); + } + + @Test + public void shouldReturnPropertyDetails() { + // Given + Property property = createTestProperty("APPROVED"); + stubQueryReturning(property); + + APIGatewayProxyRequestEvent event = TestHelpers.createApiGatewayEvent( + "GET", + "/properties/{country}/{city}/{street}/{number}", + Map.of("country", "usa", "city", "anytown", "street", "main-street", "number", "123") + ); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(event, context); + + // Then + assertEquals(200, response.getStatusCode()); + assertTrue(response.getBody().contains("250000")); + verify(propertyTable).query(any(QueryEnhancedRequest.class)); + } + + @Test + public void shouldReturn404WhenPropertyNotFound() { + // Given - query returns empty list + stubQueryReturning(); // no properties + + APIGatewayProxyRequestEvent event = TestHelpers.createApiGatewayEvent( + "GET", + "/properties/{country}/{city}/{street}/{number}", + Map.of("country", "usa", "city", "anytown", "street", "main-street", "number", "999") + ); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(event, context); + + // Then - returns 200 with an empty array (the handler returns the query result as-is) + assertEquals(200, response.getStatusCode()); + assertEquals("[]", response.getBody()); + } + + @Test + public void shouldReturn404WhenPropertyNotApproved() { + // Given - the query has a filter expression for status=APPROVED, + // so non-approved properties are filtered out at the DynamoDB level + stubQueryReturning(); // no properties returned after filter + + APIGatewayProxyRequestEvent event = TestHelpers.createApiGatewayEvent( + "GET", + "/properties/{country}/{city}/{street}/{number}", + Map.of("country", "usa", "city", "anytown", "street", "main-street", "number", "123") + ); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(event, context); + + // Then - returns 200 with empty array since the filter excludes non-approved + assertEquals(200, response.getStatusCode()); + assertEquals("[]", response.getBody()); + } + + @Test + public void shouldReturn400ForNonGetMethod() { + // Given + APIGatewayProxyRequestEvent event = TestHelpers.createApiGatewayEvent( + "POST", + "/search/{country}/{city}", + Map.of("country", "usa", "city", "anytown") + ); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(event, context); + + // Then + assertEquals(400, response.getStatusCode()); + assertTrue(response.getBody().contains("Method not allowed")); + verifyNoInteractions(propertyTable); + } +} diff --git a/unicorn_web/SearchService/src/test/java/search/helpers/TestHelpers.java b/unicorn_web/SearchService/src/test/java/search/helpers/TestHelpers.java new file mode 100644 index 0000000..4662096 --- /dev/null +++ b/unicorn_web/SearchService/src/test/java/search/helpers/TestHelpers.java @@ -0,0 +1,35 @@ +package search.helpers; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; + +import java.util.Map; + +/** + * Test helper utilities for building API Gateway proxy request events + * used by the Search service tests. + */ +public final class TestHelpers { + + private TestHelpers() { + // Utility class + } + + /** + * Creates an API Gateway proxy request event. + * + * @param httpMethod the HTTP method (e.g. GET, POST) + * @param resource the API Gateway resource path template + * @param pathParameters the path parameters map + * @return an APIGatewayProxyRequestEvent + */ + public static APIGatewayProxyRequestEvent createApiGatewayEvent( + String httpMethod, + String resource, + Map pathParameters) { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setHttpMethod(httpMethod); + event.setResource(resource); + event.setPathParameters(pathParameters); + return event; + } +}