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;
+ }
+}