diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9e4201c..facf2f5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: Create Infra on LocalStack
+name: Deploy with Terraform
on:
push:
@@ -15,8 +15,8 @@ on:
workflow_dispatch:
jobs:
- infrastructure-check:
- name: Setup infrastructure using Terraform
+ test:
+ name: Run Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -81,6 +81,18 @@ jobs:
run: |
localstack logs
+ - name: Stop LocalStack
+ run: |
+ localstack stop
+
+ - name: Run Testcontainers tests
+ env:
+ AWS_ACCESS_KEY_ID: test
+ AWS_SECRET_ACCESS_KEY: test
+ AWS_REGION: us-east-1
+ run: |
+ mvn test -Dtest=dev.ancaghenade.shipmentlistdemo.integrationtests.ShipmentServiceIntegrationTest
+
- name: Send a Slack notification
if: failure() || github.event_name != 'pull_request'
uses: ravsamhq/notify-slack-action@v2
diff --git a/.gitignore b/.gitignore
index 27ff1f9..38e54db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
+.DS_Store
### STS ###
.apt_generated
diff --git a/pom.xml b/pom.xml
index f771a10..072dfa2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,6 +22,7 @@
17
17
3.2.0
+ 1.19.7
UTF-8
@@ -69,16 +70,57 @@
spring-boot-starter-test
test
+
+ org.testcontainers
+ testcontainers
+ ${testcontainers.version}
+ test
+
org.json
json
20231013
+
+
+ org.testcontainers
+ localstack
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+
+
+ software.amazon.awssdk
+ lambda
+
+
+ software.amazon.awssdk
+ iam
+
+
+ software.amazon.awssdk
+ sns
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ ${testcontainers.version}
+ pom
+ import
+
software.amazon.awssdk
bom
diff --git a/src/test/java/dev/ancaghenade/shipmentlistdemo/ShipmentListDemoApplicationTests.java b/src/test/java/dev/ancaghenade/shipmentlistdemo/ShipmentListDemoApplicationTests.java
index 63ce740..ed5408b 100644
--- a/src/test/java/dev/ancaghenade/shipmentlistdemo/ShipmentListDemoApplicationTests.java
+++ b/src/test/java/dev/ancaghenade/shipmentlistdemo/ShipmentListDemoApplicationTests.java
@@ -7,7 +7,6 @@
class ShipmentListDemoApplicationTests {
@Test
- void contextLoads() {
- }
+ void contextLoads() {}
}
diff --git a/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/LambdaIntegrationTest.java b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/LambdaIntegrationTest.java
new file mode 100644
index 0000000..69d63bb
--- /dev/null
+++ b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/LambdaIntegrationTest.java
@@ -0,0 +1,170 @@
+package dev.ancaghenade.shipmentlistdemo.integrationtests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class LambdaIntegrationTest extends LocalStackSetupConfigurations {
+
+ @BeforeAll
+ public static void setup() throws IOException, InterruptedException, org.json.JSONException {
+ LocalStackSetupConfigurations.setupConfig();
+ localStack.followOutput(logConsumer);
+
+ createClients();
+
+ createS3Bucket();
+ createDynamoDBResources();
+ createIAMRole();
+ createLambdaResources();
+ createBucketNotificationConfiguration();
+ createSNS();
+ createSQS();
+ createSNSSubscription();
+
+ lambdaClient.close();
+ snsClient.close();
+ sqsClient.close();
+ iamClient.close();
+
+ }
+
+ @Test
+ @Order(1)
+ void testFileAddWatermarkInLambda() {
+
+ // prepare the file to upload
+ var imageData = new byte[0];
+ try {
+ imageData = Files.readAllBytes(Path.of("src/test/java/resources/cat.jpg"));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ var resource = new ByteArrayResource(imageData) {
+ @Override
+ public String getFilename() {
+ return "cat.jpg";
+ }
+ };
+
+ var originalHash = applyHash(imageData);
+
+ var shipmentId = "3317ac4f-1f9b-4bab-a974-4aa9876d5547";
+ // build the URL with the id as a path variable
+ var postUrl = "/api/shipment/" + shipmentId + "/image/upload";
+ var getUrl = "/api/shipment/" + shipmentId + "/image/download";
+
+ // set the request headers
+ var headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ // request body with the file resource and headers
+ MultiValueMap < String, Object > requestBody = new LinkedMultiValueMap < > ();
+ requestBody.add("file", resource);
+ HttpEntity < MultiValueMap < String, Object >> requestEntity = new HttpEntity < > (requestBody,
+ headers);
+
+ ResponseEntity < String > postResponse = restTemplate.exchange(BASE_URL + postUrl,
+ HttpMethod.POST, requestEntity, String.class);
+
+ assertEquals(HttpStatus.OK, postResponse.getStatusCode());
+
+ // give the Lambda time to start up and process the image
+ try {
+ Thread.sleep(15000);
+
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ ResponseEntity < byte[] > responseEntity = restTemplate.exchange(BASE_URL + getUrl,
+ HttpMethod.GET, null, byte[].class);
+
+ assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
+
+ var watermarkHash = applyHash(responseEntity.getBody());
+
+ assertNotEquals(originalHash, watermarkHash);
+
+ }
+
+ @Test
+ @Order(2)
+ void testFileProcessedInLambdaHasMetadata() {
+ var getItemRequest = GetItemRequest.builder()
+ .tableName("shipment")
+ .key(Map.of(
+ "shipmentId",
+ AttributeValue.builder().s("3317ac4f-1f9b-4bab-a974-4aa9876d5547").build())).build();
+
+ var getItemResponse = dynamoDbClient.getItem(getItemRequest);
+
+ dynamoDbClient.getItem(getItemRequest);
+ GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+ .bucket(BUCKET_NAME)
+ .key(getItemResponse.item().get("imageLink").s())
+ .build();
+ try {
+ // already processed objects have a metadata field added, not be processed again
+ var s3ObjectResponse = s3Client.getObject(getObjectRequest);
+ assertTrue(s3ObjectResponse.response().metadata().entrySet().stream().anyMatch(
+ entry -> entry.getKey().equals("exclude-lambda") && entry.getValue().equals("true")));
+ } catch (NoSuchKeyException noSuchKeyException) {
+ noSuchKeyException.printStackTrace();
+ }
+ dynamoDbClient.close();
+ s3Client.close();
+
+ }
+
+ private String applyHash(byte[] data) {
+ String hashValue = null;
+ try {
+ var digest = MessageDigest.getInstance("SHA-256");
+
+ // get the hash of the byte array
+ var hash = digest.digest(data);
+
+ // convert the hash bytes to a hexadecimal representation
+ var hexString = new StringBuilder();
+ for (byte b: hash) {
+ var hex = Integer.toHexString(0xff & b);
+ if (hex.length() == 1) {
+ hexString.append('0');
+ }
+ hexString.append(hex);
+ }
+ hashValue = hexString.toString();
+ System.out.println("Hash value: " + hashValue);
+ } catch (NoSuchAlgorithmException e) {
+ e.printStackTrace();
+ }
+ return hashValue;
+ }
+}
diff --git a/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/LocalStackSetupConfigurations.java b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/LocalStackSetupConfigurations.java
new file mode 100644
index 0000000..bd30b12
--- /dev/null
+++ b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/LocalStackSetupConfigurations.java
@@ -0,0 +1,443 @@
+package dev.ancaghenade.shipmentlistdemo.integrationtests;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.json.JSONObject;
+import org.junit.jupiter.api.BeforeAll;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.Container.ExecResult;
+import org.testcontainers.containers.localstack.LocalStackContainer;
+import org.testcontainers.containers.localstack.LocalStackContainer.Service;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
+import org.testcontainers.utility.DockerImageName;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.BillingMode;
+import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
+import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
+import software.amazon.awssdk.services.dynamodb.model.KeyType;
+import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
+import software.amazon.awssdk.services.iam.IamClient;
+import software.amazon.awssdk.services.iam.model.AttachRolePolicyRequest;
+import software.amazon.awssdk.services.iam.model.CreateRoleRequest;
+import software.amazon.awssdk.services.iam.model.GetRoleRequest;
+import software.amazon.awssdk.services.lambda.LambdaClient;
+import software.amazon.awssdk.services.lambda.model.AddPermissionRequest;
+import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest;
+import software.amazon.awssdk.services.lambda.model.Environment;
+import software.amazon.awssdk.services.lambda.model.FunctionCode;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
+import software.amazon.awssdk.services.s3.model.Event;
+import software.amazon.awssdk.services.s3.model.LambdaFunctionConfiguration;
+import software.amazon.awssdk.services.s3.model.NotificationConfiguration;
+import software.amazon.awssdk.services.s3.model.PutBucketNotificationConfigurationRequest;
+import software.amazon.awssdk.services.s3.model.PutBucketPolicyRequest;
+import software.amazon.awssdk.services.sns.SnsClient;
+import software.amazon.awssdk.services.sns.model.CreateTopicRequest;
+import software.amazon.awssdk.services.sns.model.SubscribeRequest;
+import software.amazon.awssdk.services.sqs.SqsClient;
+import software.amazon.awssdk.services.sqs.model.CreateQueueRequest;
+import software.amazon.awssdk.services.sqs.model.GetQueueAttributesRequest;
+import software.amazon.awssdk.services.sqs.model.QueueAttributeName;
+
+@Testcontainers
+@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)
+public class LocalStackSetupConfigurations {
+
+ @Container
+ protected static LocalStackContainer localStack =
+ new LocalStackContainer(DockerImageName.parse("localstack/localstack:latest"))
+ .withEnv("LOCALSTACK_HOST", "localhost.localstack.cloud")
+ .withEnv("LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT", "60")
+ .withEnv("DEBUG", "1");
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(LocalStackSetupConfigurations.class);
+ protected static Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER);
+ protected TestRestTemplate restTemplate = new TestRestTemplate();
+
+ protected static final String BUCKET_NAME = "shipment-picture-bucket";
+ protected static String BASE_URL = "http://localhost:8081";
+ protected static Region region = Region.of(localStack.getRegion());
+ protected static S3Client s3Client;
+ protected static DynamoDbClient dynamoDbClient;
+ protected static LambdaClient lambdaClient;
+ protected static SqsClient sqsClient;
+ protected static SnsClient snsClient;
+ protected static IamClient iamClient;
+ protected static Logger logger = LoggerFactory.getLogger(LocalStackSetupConfigurations.class);
+ protected static ObjectMapper objectMapper = new ObjectMapper();
+ protected static URI localStackEndpoint;
+
+ @BeforeAll()
+ protected static void setupConfig() {
+ localStackEndpoint = localStack.getEndpoint();
+ }
+
+ @DynamicPropertySource
+ static void overrideConfigs(DynamicPropertyRegistry registry) {
+ registry.add("aws.s3.endpoint",
+ () -> localStackEndpoint);
+ registry.add(
+ "aws.dynamodb.endpoint", () -> localStackEndpoint);
+ registry.add(
+ "aws.sqs.endpoint", () -> localStackEndpoint);
+ registry.add(
+ "aws.sns.endpoint", () -> localStackEndpoint);
+ registry.add("aws.credentials.secret-key", localStack::getSecretKey);
+ registry.add("aws.credentials.access-key", localStack::getAccessKey);
+ registry.add("aws.region", localStack::getRegion);
+ registry.add("shipment-picture-bucket", () -> BUCKET_NAME);
+ }
+
+ protected static void createClients() {
+ s3Client = S3Client.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(LocalStackContainer.Service.S3))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
+ .build();
+ dynamoDbClient = DynamoDbClient.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(Service.DYNAMODB))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
+ .build();
+ lambdaClient = LambdaClient.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(Service.LAMBDA))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
+ .build();
+ sqsClient = SqsClient.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(Service.SQS))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
+ .build();
+ snsClient = SnsClient.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(Service.SNS))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
+ .build();
+ iamClient = IamClient.builder()
+ .region(Region.AWS_GLOBAL)
+ .endpointOverride(localStack.getEndpointOverride(Service.IAM))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
+ .build();
+ }
+
+ protected static void createIAMRole() {
+ var roleName = "lambda_exec_role";
+ // assume role policy document
+ var assumeRolePolicyDocument = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}";
+
+ // call createRole API with request using name and policy
+ iamClient.createRole(CreateRoleRequest.builder()
+ .roleName(roleName)
+ .assumeRolePolicyDocument(assumeRolePolicyDocument)
+ .build());
+
+ var policyArn = "arn:aws:iam::aws:policy/AmazonS3FullAccess";
+
+ // attach s3 full access policy to role
+ iamClient.attachRolePolicy(
+ AttachRolePolicyRequest.builder()
+ .roleName(roleName)
+ .policyArn(policyArn)
+ .build());
+
+ }
+
+ private static String getQueueUrl(SqsClient sqsClient, String queueName) {
+ return sqsClient.getQueueUrl(r -> r.queueName(queueName)).queueUrl();
+ }
+
+ protected static void createSNSSubscription() {
+ String topicArn = snsClient.listTopics().topics().get(0).topicArn();
+ // get the queue URL
+ String queueName = "update_shipment_picture_queue";
+
+ // create get queue attributes request
+ var request = GetQueueAttributesRequest.builder()
+ .queueUrl(getQueueUrl(sqsClient, queueName))
+ .attributeNames(QueueAttributeName.QUEUE_ARN)
+ .build();
+
+ // call API with the request and get the attributes
+ var response = sqsClient.getQueueAttributes(request);
+ // extract queue arn
+ String queueArn = response.attributes().get(QueueAttributeName.QUEUE_ARN);
+
+ // create the queue subscribe to topic request
+ var subscribeRequest = SubscribeRequest.builder()
+ .topicArn(topicArn)
+ .protocol("sqs")
+ .endpoint(queueArn)
+ .build();
+
+ // call subscribe API with request
+ snsClient.subscribe(subscribeRequest);
+ }
+
+ protected static void createSQS() {
+ // queue name
+ var queueName = "update_shipment_picture_queue";
+
+ // request to create queue
+ var request = CreateQueueRequest.builder()
+ .queueName(queueName)
+ .build();
+
+ // call createQueue API with the request
+ sqsClient.createQueue(request);
+ }
+
+ protected static void createSNS() {
+ // topic name
+ var topicName = "update_shipment_picture_topic";
+
+ // create topic request
+ var request = CreateTopicRequest.builder()
+ .name(topicName)
+ .build();
+
+ // call createTopic API with request
+ snsClient.createTopic(request);
+ }
+
+ protected static void createBucketNotificationConfiguration()
+ throws IOException, InterruptedException, org.json.JSONException {
+
+ try {
+ // lambda needs to be in state "Active" in order to proceed with adding permissions
+ // this can take 2-3 seconds to reach
+ var result = localStack.execInContainer(formatCommand(
+ "awslocal lambda get-function --function-name shipment-picture-lambda-validator"));
+ var obj = new JSONObject(result.getStdout()).getJSONObject("Configuration");
+ var state = obj.getString("State");
+ while (!state.equals("Active")) {
+ result = localStack.execInContainer(formatCommand(
+ "awslocal lambda get-function --function-name shipment-picture-lambda-validator"));
+ obj = new JSONObject(result.getStdout()).getJSONObject("Configuration");
+ state = obj.getString("State");
+ }
+
+ // create notification configuration
+ var notificationConfiguration = NotificationConfiguration.builder()
+ .lambdaFunctionConfigurations(
+ LambdaFunctionConfiguration.builder().id("shipment-picture-lambda-validator")
+ .lambdaFunctionArn(
+ "arn:aws:lambda:" + region +
+ ":000000000000:function:shipment-picture-lambda-validator")
+ .events(Event.S3_OBJECT_CREATED).build()).build();
+
+ // create the request for trigger
+ var request = PutBucketNotificationConfigurationRequest.builder()
+ .bucket(BUCKET_NAME)
+ .notificationConfiguration(notificationConfiguration)
+ .build();
+
+ // call the PutBucketNotificationConfiguration API with the request
+ s3Client.putBucketNotificationConfiguration(request);
+
+ } catch (Exception e) {
+ System.err.println("Error creating bucket notification configuration: " + e.getMessage());
+ }
+ }
+
+ protected static void createLambdaResources() {
+ var functionName = "shipment-picture-lambda-validator";
+ var runtime = "java17";
+ var handler = "dev.ancaghenade.shipmentpicturelambdavalidator.ServiceHandler::handleRequest";
+ var zipFilePath = "shipment-picture-lambda-validator/target/shipment-picture-lambda-validator.jar";
+ var sourceArn = "arn:aws:s3:000000000000:" + BUCKET_NAME;
+ var statementId = "AllowExecutionFromS3Bucket";
+ var action = "lambda:InvokeFunction";
+ var principal = "s3.amazonaws.com";
+
+ var getRoleResponse = iamClient.getRole(GetRoleRequest.builder()
+ .roleName("lambda_exec_role")
+ .build());
+
+ var roleArn = getRoleResponse.role().arn();
+
+ try {
+ var zipFileBytes = Files.readAllBytes(Paths.get(zipFilePath));
+ var zipFileBuffer = ByteBuffer.wrap(zipFileBytes);
+
+ var createFunctionRequest = CreateFunctionRequest.builder()
+ .functionName(functionName)
+ .runtime(runtime)
+ .handler(handler)
+ .code(FunctionCode.builder().zipFile(SdkBytes.fromByteBuffer(zipFileBuffer)).build())
+ .role(roleArn)
+ .timeout(60)
+ .memorySize(512)
+ // bucket name that is being passed as env var because it's randomly generated
+ .environment(
+ Environment.builder().variables(Collections.singletonMap("BUCKET", BUCKET_NAME))
+ .build())
+ .build();
+
+ lambdaClient.createFunction(
+ createFunctionRequest);
+
+ var request = AddPermissionRequest.builder()
+ .functionName(functionName)
+ .statementId(statementId)
+ .action(action)
+ .principal(principal)
+ .sourceArn(sourceArn)
+ .sourceAccount("000000000000")
+ .build();
+
+ // call the addPermission API with the request
+ lambdaClient.addPermission(request);
+
+ } catch (Exception e) {
+ System.err.println("Error creating Lambda function: " + e.getMessage());
+ }
+ }
+
+ protected static void createDynamoDBResources() {
+
+ // table name
+ var tableName = "shipment";
+
+ // attribute definitions
+ var attributeDefinition = AttributeDefinition.builder()
+ .attributeName("shipmentId")
+ .attributeType(ScalarAttributeType.S)
+ .build();
+
+ // create key schema
+ var keySchemaElement = KeySchemaElement.builder()
+ .attributeName("shipmentId")
+ .keyType(KeyType.HASH)
+ .build();
+
+ // CreateTableRequest with table name, attribute definitions, key schema, and billing mode
+ var createTableRequest = CreateTableRequest.builder()
+ .tableName(tableName)
+ .attributeDefinitions(attributeDefinition)
+ .keySchema(keySchemaElement)
+ .billingMode(BillingMode.PAY_PER_REQUEST)
+ .build();
+
+ // createTable operation to create the table
+ dynamoDbClient.createTable(createTableRequest);
+
+ // create attribute values for the item
+ var shipmentId = AttributeValue.builder().s("3317ac4f-1f9b-4bab-a974-4aa9876d5547")
+ .build();
+ var recipientName = AttributeValue.builder().s("Harry Potter").build();
+ // add other attributes as needed
+
+ // create a map to hold the item attribute values
+ var item = new HashMap < String,
+ AttributeValue > ();
+ item.put("shipmentId", shipmentId);
+ item.put("recipient", AttributeValue.builder()
+ .m(Map.of(
+ "name", recipientName,
+ "address", AttributeValue.builder()
+ .m(Map.of(
+ "postalCode", AttributeValue.builder().s("LNDNGB").build(),
+ "street", AttributeValue.builder().s("Privet Drive").build(),
+ "number", AttributeValue.builder().s("4").build(),
+ "city", AttributeValue.builder().s("Little Whinging").build(),
+ "additionalInfo", AttributeValue.builder().s("").build()
+ ))
+ .build()
+ ))
+ .build());
+
+ var senderName = AttributeValue.builder().s("Warehouse of Unicorns").build();
+
+ item.put("sender", AttributeValue.builder()
+ .m(Map.of(
+ "name", senderName,
+ "address", AttributeValue.builder()
+ .m(Map.of(
+ "postalCode", AttributeValue.builder().s("98653").build(),
+ "street", AttributeValue.builder().s("47th Street").build(),
+ "number", AttributeValue.builder().s("5").build(),
+ "city", AttributeValue.builder().s("Townsville").build(),
+ "additionalInfo", AttributeValue.builder().s("").build()
+ ))
+ .build()
+ ))
+ .build());
+ item.put("weight", AttributeValue.builder().s("2.3").build());
+
+ // create a PutItemRequest with the table name and item
+ var putItemRequest = PutItemRequest.builder()
+ .tableName(tableName)
+ .item(item)
+ .build();
+
+ // call the putItem operation to add the item to the table
+ dynamoDbClient.putItem(putItemRequest);
+ }
+
+ protected static void createS3Bucket() {
+ // bucket name
+ var bucketName = BUCKET_NAME;
+ // CreateBucketRequest with the bucket name
+ var createBucketRequest = CreateBucketRequest.builder()
+ .bucket(bucketName)
+ .build();
+ // createBucket operation to create the bucket
+ s3Client.createBucket(createBucketRequest);
+
+ var putBucketPolicyRequest = PutBucketPolicyRequest.builder()
+ .bucket(bucketName)
+ .policy(
+ "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"AllowLambdaInvoke\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::" +
+ BUCKET_NAME + "/*\"}]}")
+ .build();
+
+ s3Client.putBucketPolicy(putBucketPolicyRequest);
+ }
+
+ protected static ExecResult executeInContainer(String command) throws Exception {
+
+ final
+ var execResult = localStack.execInContainer(formatCommand(command));
+ // assertEquals(0, execResult.getExitCode());
+
+ final
+ var logs = execResult.getStdout() + execResult.getStderr();
+ logger.info(logs);
+ logger.error(execResult.getExitCode() != 0 ? execResult + " - DOES NOT WORK" : "");
+ return execResult;
+ }
+
+ private static String[] formatCommand(String command) {
+ return command.split(" ");
+ }
+}
diff --git a/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/MessageReceiverIntegrationTest.java b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/MessageReceiverIntegrationTest.java
new file mode 100644
index 0000000..8b3eb65
--- /dev/null
+++ b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/MessageReceiverIntegrationTest.java
@@ -0,0 +1,98 @@
+package dev.ancaghenade.shipmentlistdemo.integrationtests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+public class MessageReceiverIntegrationTest extends LocalStackSetupConfigurations {
+
+ @BeforeAll
+ public static void setup() throws IOException, InterruptedException, org.json.JSONException {
+ LocalStackSetupConfigurations.setupConfig();
+
+ localStack.followOutput(logConsumer);
+
+ createClients();
+
+ createS3Bucket();
+ createDynamoDBResources();
+ createIAMRole();
+ createLambdaResources();
+ createBucketNotificationConfiguration();
+ createSNS();
+ createSQS();
+ createSNSSubscription();
+
+ lambdaClient.close();
+ snsClient.close();
+ sqsClient.close();
+ iamClient.close();
+
+ }
+
+ @Test
+ void testSNSSQSMessageReceiver() {
+ var imageData = new byte[0];
+ try {
+ imageData = Files.readAllBytes(Path.of("src/test/java/resources/cat.jpg"));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ var resource = new ByteArrayResource(imageData) {
+ @Override
+ public String getFilename() {
+ return "cat.jpg";
+ }
+ };
+
+ var shipmentId = "3317ac4f-1f9b-4bab-a974-4aa9876d5547";
+ // build the URL with the id as a path variable
+ var url = "/api/shipment/" + shipmentId + "/image/upload";
+ // set the request headers
+ var headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ // request body with the file resource and headers
+ MultiValueMap < String, Object > requestBody = new LinkedMultiValueMap < > ();
+ requestBody.add("file", resource);
+ HttpEntity < MultiValueMap < String, Object >> requestEntity = new HttpEntity < > (requestBody,
+ headers);
+
+ ResponseEntity < String > responseEntity = restTemplate.exchange(BASE_URL + url,
+ HttpMethod.POST, requestEntity, String.class);
+
+ assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
+
+ // give the Lambda time to start up and process the image + send the message to SQS
+ try {
+ Thread.sleep(20000);
+
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ var sseUrl = "/push-endpoint";
+
+ ResponseEntity < String > sseEndpointResponse = restTemplate.getForEntity(BASE_URL + sseUrl,
+ String.class);
+ assertEquals(HttpStatus.OK, sseEndpointResponse.getStatusCode());
+ assertNotNull(sseEndpointResponse.getBody());
+ assertTrue(sseEndpointResponse.getBody().contains(shipmentId));
+
+ }
+}
diff --git a/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/ShipmentServiceIntegrationTest.java b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/ShipmentServiceIntegrationTest.java
new file mode 100644
index 0000000..76e214e
--- /dev/null
+++ b/src/test/java/dev/ancaghenade/shipmentlistdemo/integrationtests/ShipmentServiceIntegrationTest.java
@@ -0,0 +1,170 @@
+package dev.ancaghenade.shipmentlistdemo.integrationtests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import dev.ancaghenade.shipmentlistdemo.entity.Shipment;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.testcontainers.containers.localstack.LocalStackContainer;
+import org.testcontainers.containers.localstack.LocalStackContainer.Service;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.s3.S3Client;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+@ActiveProfiles("dev")
+class ShipmentServiceIntegrationTest extends LocalStackSetupConfigurations {
+
+ @BeforeAll
+ public static void setup() {
+ LocalStackSetupConfigurations.setupConfig();
+
+ localStack.followOutput(logConsumer);
+
+ s3Client = S3Client.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(LocalStackContainer.Service.S3))
+ .build();
+ dynamoDbClient = DynamoDbClient.builder()
+ .region(region)
+ .endpointOverride(localStack.getEndpointOverride(Service.DYNAMODB))
+ .build();
+
+ createS3Bucket();
+ createDynamoDBResources();
+
+ }
+
+ @Test
+ @Order(1)
+ void testFileUploadToS3() throws Exception {
+ // Prepare the file to upload
+ var imageData = new byte[0];
+ try {
+ imageData = Files.readAllBytes(Path.of("src/test/java/resources/cat.jpg"));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ var resource = new ByteArrayResource(imageData) {
+ @Override
+ public String getFilename() {
+ return "cat.jpg";
+ }
+ };
+ var shipmentId = "3317ac4f-1f9b-4bab-a974-4aa9876d5547";
+ // build the URL with the id as a path variable
+ var url = "/api/shipment/" + shipmentId + "/image/upload";
+ // set the request headers
+ var headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ // request body with the file resource and headers
+ MultiValueMap < String, Object > requestBody = new LinkedMultiValueMap < > ();
+ requestBody.add("file", resource);
+ HttpEntity < MultiValueMap < String, Object >> requestEntity = new HttpEntity < > (requestBody,
+ headers);
+
+ ResponseEntity < String > responseEntity = restTemplate.exchange(BASE_URL + url,
+ HttpMethod.POST, requestEntity, String.class);
+
+ assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
+ var execResult = executeInContainer(
+ "awslocal s3api list-objects --bucket shipment-picture-bucket --query length(Contents[])");
+ assertEquals(String.valueOf(1), execResult.getStdout().trim());
+ }
+
+ @Test
+ @Order(2)
+ void testFileDownloadFromS3() {
+
+ var shipmentId = "3317ac4f-1f9b-4bab-a974-4aa9876d5547";
+ // build the URL with the id as a path variable
+ var url = "/api/shipment/" + shipmentId + "/image/download";
+
+ ResponseEntity < byte[] > responseEntity = restTemplate.exchange(BASE_URL + url,
+ HttpMethod.GET, null, byte[].class);
+
+ assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
+ // object is not empty
+ assertNotNull(responseEntity.getBody());
+ }
+
+ @Test
+ @Order(3)
+ void testAddShipmentToDynamoDB() throws IOException {
+
+ var url = "/api/shipment";
+ // set the request headers
+
+ var json = new File("src/test/java/resources/shipmentToUpload.json");
+ var shipment = objectMapper.readValue(json, Shipment.class);
+
+ var headers = new HttpHeaders();
+ headers.setContentType(MediaType.valueOf(MediaType.APPLICATION_JSON_VALUE));
+
+ HttpEntity < Shipment > requestEntity = new HttpEntity < > (shipment,
+ headers);
+
+ ResponseEntity < String > responseEntity = restTemplate.exchange(BASE_URL + url,
+ HttpMethod.POST, requestEntity, String.class);
+
+ assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
+
+ }
+
+ @Test
+ @Order(4)
+ void testGetTwoShipmentsFromDynamoDB() {
+
+ var url = "/api/shipment";
+ // set the request headers
+ ResponseEntity < List < Shipment >> responseEntity = restTemplate.exchange(BASE_URL + url,
+ HttpMethod.GET, null, new ParameterizedTypeReference < > () {});
+
+ if (responseEntity.getStatusCode().is2xxSuccessful()) {
+ List < Shipment > shipmentList = responseEntity.getBody();
+ assertEquals(2, shipmentList.size());
+ }
+ }
+
+ @Test
+ @Order(5)
+ void testDeleteShipmentFromDynamoDB() {
+
+ var url = "/api/shipment";
+ var shipmentId = "/3317ac4f-1f9b-4bab-a974-4aa9876d5547";
+
+ // set the request headers
+ ResponseEntity < String > deleteResponseEntity = restTemplate.exchange(BASE_URL + url + shipmentId,
+ HttpMethod.DELETE, null, String.class);
+
+ assertEquals(HttpStatus.OK, deleteResponseEntity.getStatusCode());
+ assertEquals("Shipment has been deleted", deleteResponseEntity.getBody());
+
+ ResponseEntity < List < Shipment >> getResponseEntity = restTemplate.exchange(BASE_URL + url,
+ HttpMethod.GET, null, new ParameterizedTypeReference < > () {});
+
+ if (getResponseEntity.getStatusCode().is2xxSuccessful()) {
+ List < Shipment > shipmentList = getResponseEntity.getBody();
+ assertEquals(1, shipmentList.size());
+ }
+ }
+}
diff --git a/src/test/java/resources/cat.jpg b/src/test/java/resources/cat.jpg
new file mode 100644
index 0000000..1444a83
Binary files /dev/null and b/src/test/java/resources/cat.jpg differ
diff --git a/src/test/java/resources/lambdaConfig.json b/src/test/java/resources/lambdaConfig.json
new file mode 100644
index 0000000..37a88c4
--- /dev/null
+++ b/src/test/java/resources/lambdaConfig.json
@@ -0,0 +1,11 @@
+{
+ "LambdaFunctionConfigurations": [
+ {
+ "Id": "shipment-picture-lambda-validator",
+ "LambdaFunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:shipment-picture-lambda-validator",
+ "Events": [
+ "s3:ObjectCreated:*"
+ ]
+ }
+ ]
+ }
diff --git a/src/test/java/resources/shipment.json b/src/test/java/resources/shipment.json
new file mode 100644
index 0000000..6740e8d
--- /dev/null
+++ b/src/test/java/resources/shipment.json
@@ -0,0 +1,25 @@
+{
+ "shipmentId": "3317ac4f-1f9b-4bab-a974-4aa9876d5547",
+ "recipient": {
+ "name": "Harry Potter",
+ "address": {
+ "postalCode": "LNDNGB",
+ "street": "Privet Drive",
+ "number": "4",
+ "city": "Little Whinging",
+ "additionalInfo": ""
+ }
+ },
+ "sender": {
+ "name": "Warehouse of Unicorns",
+ "address": {
+ "postalCode": "98653",
+ "street": "47th Street",
+ "number": "5",
+ "city": "Townsville",
+ "additionalInfo": ""
+ }
+ },
+ "weight": 2.3,
+ "imageLink": null
+ }
diff --git a/src/test/java/resources/shipmentToUpload.json b/src/test/java/resources/shipmentToUpload.json
new file mode 100644
index 0000000..859451f
--- /dev/null
+++ b/src/test/java/resources/shipmentToUpload.json
@@ -0,0 +1,25 @@
+{
+ "shipmentId": "a7ba93a2-bc88-463f-a27e-3dbcc8ef436e",
+ "recipient": {
+ "name": "Home Sweet Home",
+ "address": {
+ "postalCode": "98653",
+ "street": "47th Street",
+ "number": "4",
+ "city": "Springfield",
+ "additionalInfo": null
+ }
+ },
+ "sender": {
+ "name": "Warehouse of Unicorns",
+ "address": {
+ "postalCode": "98653",
+ "street": "47th Street",
+ "number": "4",
+ "city": "Townsville",
+ "additionalInfo": null
+ }
+ },
+ "weight": 2.3,
+ "imageLink": null
+ }