diff --git a/powertools-idempotency/powertools-idempotency-dynamodb/pom.xml b/powertools-idempotency/powertools-idempotency-dynamodb/pom.xml index 28f3a6bf6..d5e31d255 100644 --- a/powertools-idempotency/powertools-idempotency-dynamodb/pom.xml +++ b/powertools-idempotency/powertools-idempotency-dynamodb/pom.xml @@ -77,6 +77,16 @@ junit-jupiter-engine test + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + org.slf4j slf4j-simple @@ -89,15 +99,6 @@ test-jar test - - - com.amazonaws - DynamoDBLocal - - - 2.2.0 - test - software.amazon.awssdk s3 @@ -125,6 +126,7 @@ org.graalvm.buildtools native-maven-plugin + true powertools-idempotency-dynamodb @@ -149,57 +151,6 @@ - - - org.apache.maven.plugins - maven-dependency-plugin - - - copy-dynamodb-local - generate-test-resources - - copy-dependencies - - - test - false - - software.amazon.lambda - ${project.build.directory}/dynamodb-local - - - - - - - org.codehaus.mojo - exec-maven-plugin - - - start-dynamodb-local - process-test-classes - - exec - - - java - ${project.build.directory}/dynamodb-local - - -Djava.library.path=${project.build.directory}/dynamodb-local - -cp - ${project.build.directory}/dynamodb-local/* - com.amazonaws.services.dynamodbv2.local.main.ServerRunner - -inMemory - -port - 8000 - - true - true - - - - org.apache.maven.plugins maven-jar-plugin @@ -212,16 +163,6 @@ - - - org.apache.maven.plugins - maven-surefire-plugin - - - http://localhost:8000 - - - dev.aspectj aspectj-maven-plugin diff --git a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBConfig.java b/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBConfig.java deleted file mode 100644 index 9f6875689..000000000 --- a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBConfig.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2023 Amazon.com, Inc. or its affiliates. - * Licensed under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package software.amazon.lambda.powertools.idempotency.persistence.dynamodb; - -import java.net.URI; - -import org.junit.jupiter.api.BeforeAll; - -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; -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.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.ResourceInUseException; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class DynamoDBConfig { - protected static final String TABLE_NAME = "idempotency_table"; - protected static DynamoDbClient client; - - @BeforeAll - static void setupDynamo() { - String endpoint = System.getProperty("dynamodb.endpoint", "http://localhost:8000"); - - client = DynamoDbClient.builder() - .httpClient(UrlConnectionHttpClient.builder().build()) - .region(Region.EU_WEST_1) - .endpointOverride(URI.create(endpoint)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create("FAKE", "FAKE"))) - .build(); - - try { - client.createTable(CreateTableRequest.builder() - .tableName(TABLE_NAME) - .keySchema(KeySchemaElement.builder().keyType(KeyType.HASH).attributeName("id").build()) - .attributeDefinitions( - AttributeDefinition.builder().attributeName("id").attributeType(ScalarAttributeType.S) - .build()) - .billingMode(BillingMode.PAY_PER_REQUEST) - .build()); - } catch (ResourceInUseException e) { - // Table already exists, ignore - } catch (Exception e) { - throw new RuntimeException("Failed to create DynamoDB table", e); - } - } -} diff --git a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBPersistenceStoreTest.java b/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBPersistenceStoreTest.java index b5c816286..6d4fb8d85 100644 --- a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBPersistenceStoreTest.java +++ b/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/DynamoDBPersistenceStoreTest.java @@ -16,30 +16,30 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.OptionalLong; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.SetEnvironmentVariable; +import org.mockito.ArgumentCaptor; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 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.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import software.amazon.lambda.powertools.idempotency.Constants; import software.amazon.lambda.powertools.idempotency.IdempotencyConfig; import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException; @@ -47,342 +47,293 @@ import software.amazon.lambda.powertools.idempotency.persistence.DataRecord; /** - * These test are using DynamoDBLocal and sqlite, see https://nickolasfisher.com/blog/Configuring-an-In-Memory-DynamoDB-instance-with-Java-for-Integration-Testing - * NOTE: on a Mac with Apple Chipset, you need to use the Oracle JDK x86 64-bit + * Unit tests for DynamoDBPersistenceStore using mocked DynamoDbClient. */ -class DynamoDBPersistenceStoreTest extends DynamoDBConfig { - protected static final String TABLE_NAME_CUSTOM = "idempotency_table_custom"; - private Map key; - private DynamoDBPersistenceStore dynamoDBPersistenceStore; +class DynamoDBPersistenceStoreTest { + private static final String TABLE_NAME = "idempotency_table"; + private DynamoDbClient mockClient; + private DynamoDBPersistenceStore persistenceStore; + + @BeforeEach + void setup() { + mockClient = mock(DynamoDbClient.class); + persistenceStore = DynamoDBPersistenceStore.builder() + .withTableName(TABLE_NAME) + .withDynamoDbClient(mockClient) + .build(); + } @Test - void putRecord_shouldCreateRecordInDynamoDB() throws IdempotencyItemAlreadyExistsException { + void putRecord_shouldSendCorrectPutItemRequest() throws IdempotencyItemAlreadyExistsException { + // GIVEN Instant now = Instant.now(); long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); - dynamoDBPersistenceStore.putRecord(new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null), now); - - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - Map item = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item(); - assertThat(item).isNotNull(); - assertThat(item.get("status").s()).isEqualTo("COMPLETED"); - assertThat(item.get("expiration").n()).isEqualTo(String.valueOf(expiry)); - } + DataRecord dataRecord = new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null); - @Test - void putRecord_shouldCreateRecordInDynamoDB_IfPreviousExpired() { - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); + // WHEN + persistenceStore.putRecord(dataRecord, now); - // GIVEN: Insert a fake item with same id and expired - Map item = new HashMap<>(key); - Instant now = Instant.now(); - long expiry = now.minus(30, ChronoUnit.SECONDS).getEpochSecond(); - item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); - item.put("status", AttributeValue.builder().s(DataRecord.Status.COMPLETED.toString()).build()); - item.put("data", AttributeValue.builder().s("Fake Data").build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); - - // WHEN: call putRecord - long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); - dynamoDBPersistenceStore.putRecord( - new DataRecord("key", - DataRecord.Status.INPROGRESS, - expiry2, - null, - null), - now); - - // THEN: an item is inserted - Map itemInDb = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item(); - assertThat(itemInDb).isNotNull(); - assertThat(itemInDb.get("status").s()).isEqualTo("INPROGRESS"); - assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry2)); + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(PutItemRequest.class); + verify(mockClient).putItem(captor.capture()); + + PutItemRequest request = captor.getValue(); + assertThat(request.tableName()).isEqualTo(TABLE_NAME); + assertThat(request.item()).containsEntry("id", AttributeValue.builder().s("key").build()); + assertThat(request.item().get("status").s()).isEqualTo("COMPLETED"); + assertThat(request.item().get("expiration").n()).isEqualTo(String.valueOf(expiry)); + assertThat(request.conditionExpression()).contains("attribute_not_exists(#id)"); + assertThat(request.conditionExpression()).contains("#expiry < :now"); } @Test - void putRecord_shouldCreateRecordInDynamoDB_IfLambdaWasInProgressAndTimedOut() { - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - - // GIVEN: Insert a fake item with same id and progress expired (Lambda timed out before and we allow a new - // execution) - Map item = new HashMap<>(key); + void putRecord_shouldIncludeInProgressExpiry_whenProvided() throws IdempotencyItemAlreadyExistsException { + // GIVEN Instant now = Instant.now(); - long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond(); - long progressExpiry = now.minus(30, ChronoUnit.SECONDS).toEpochMilli(); - item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); - item.put("status", AttributeValue.builder().s(DataRecord.Status.INPROGRESS.toString()).build()); - item.put("data", AttributeValue.builder().s("Fake Data").build()); - item.put("in_progress_expiration", AttributeValue.builder().n(String.valueOf(progressExpiry)).build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); - - // WHEN: call putRecord - long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); - dynamoDBPersistenceStore.putRecord( - new DataRecord("key", - DataRecord.Status.INPROGRESS, - expiry2, - null, - null), - now); - - // THEN: an item is inserted - Map itemInDb = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item(); - assertThat(itemInDb).isNotNull(); - assertThat(itemInDb.get("status").s()).isEqualTo("INPROGRESS"); - assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry2)); + long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + long inProgressExpiry = now.plus(300, ChronoUnit.SECONDS).toEpochMilli(); + DataRecord dataRecord = new DataRecord("key", DataRecord.Status.INPROGRESS, expiry, null, null, OptionalLong.of(inProgressExpiry)); + + // WHEN + persistenceStore.putRecord(dataRecord, now); + + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(PutItemRequest.class); + verify(mockClient).putItem(captor.capture()); + + PutItemRequest request = captor.getValue(); + assertThat(request.item().get("in_progress_expiration").n()).isEqualTo(String.valueOf(inProgressExpiry)); } @Test - void putRecord_shouldThrowIdempotencyItemAlreadyExistsException_IfRecordAlreadyExist() { - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - - // GIVEN: Insert a fake item with same id - Map item = new HashMap<>(key); + void putRecord_shouldThrowIdempotencyItemAlreadyExistsException_whenConditionFails() { + // GIVEN Instant now = Instant.now(); - long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond(); - item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); // not expired - item.put("status", AttributeValue.builder().s(DataRecord.Status.COMPLETED.toString()).build()); - item.put("data", AttributeValue.builder().s("Fake Data").build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); - - // WHEN: call putRecord - long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); - DataRecord recordToInsert = new DataRecord("key", - DataRecord.Status.INPROGRESS, - expiry2, - null, - null); - assertThatThrownBy(() -> dynamoDBPersistenceStore.putRecord(recordToInsert, now)) + long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + DataRecord dataRecord = new DataRecord("key", DataRecord.Status.INPROGRESS, expiry, null, null); + + Map existingItem = new HashMap<>(); + existingItem.put("id", AttributeValue.builder().s("key").build()); + existingItem.put("status", AttributeValue.builder().s("COMPLETED").build()); + existingItem.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); + existingItem.put("data", AttributeValue.builder().s("Existing Data").build()); + + ConditionalCheckFailedException exception = ConditionalCheckFailedException.builder() + .item(existingItem) + .build(); + when(mockClient.putItem(any(PutItemRequest.class))).thenThrow(exception); + + // WHEN / THEN + assertThatThrownBy(() -> persistenceStore.putRecord(dataRecord, now)) .isInstanceOf(IdempotencyItemAlreadyExistsException.class) - // DataRecord should be present due to returnValuesOnConditionCheckFailure("ALL_OLD") - .matches(e -> ((IdempotencyItemAlreadyExistsException) e).getDataRecord().isPresent()); - - // THEN: item was not updated, retrieve the initial one - Map itemInDb = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item(); - assertThat(itemInDb).isNotNull(); - assertThat(itemInDb.get("status").s()).isEqualTo("COMPLETED"); - assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry)); - assertThat(itemInDb.get("data").s()).isEqualTo("Fake Data"); + .matches(e -> ((IdempotencyItemAlreadyExistsException) e).getDataRecord().isPresent()) + .satisfies(e -> { + IdempotencyItemAlreadyExistsException ex = (IdempotencyItemAlreadyExistsException) e; + DataRecord existingRecord = ex.getDataRecord().get(); + assertThat(existingRecord.getIdempotencyKey()).isEqualTo("key"); + assertThat(existingRecord.getStatus()).isEqualTo(DataRecord.Status.COMPLETED); + assertThat(existingRecord.getResponseData()).isEqualTo("Existing Data"); + }); } @Test - void putRecord_shouldBlockUpdate_IfRecordAlreadyExistAndProgressNotExpiredAfterLambdaTimedOut() { - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - - // GIVEN: Insert a fake item with same id - Map item = new HashMap<>(key); + void putRecord_shouldThrowWithoutDataRecord_whenConditionalCheckHasNoItem() { + // GIVEN Instant now = Instant.now(); - long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond(); // not expired - long progressExpiry = now.plus(30, ChronoUnit.SECONDS).toEpochMilli(); // not expired - item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); - item.put("status", AttributeValue.builder().s(DataRecord.Status.INPROGRESS.toString()).build()); - item.put("data", AttributeValue.builder().s("Fake Data").build()); - item.put("in_progress_expiration", AttributeValue.builder().n(String.valueOf(progressExpiry)).build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); - - // WHEN: call putRecord - long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); - DataRecord recordToInsert = new DataRecord("key", - DataRecord.Status.INPROGRESS, - expiry2, - "Fake Data 2", - null); - assertThatThrownBy(() -> dynamoDBPersistenceStore.putRecord(recordToInsert, now)) + long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + DataRecord dataRecord = new DataRecord("key", DataRecord.Status.INPROGRESS, expiry, null, null); + + ConditionalCheckFailedException exception = ConditionalCheckFailedException.builder().build(); + when(mockClient.putItem(any(PutItemRequest.class))).thenThrow(exception); + + // WHEN / THEN + assertThatThrownBy(() -> persistenceStore.putRecord(dataRecord, now)) .isInstanceOf(IdempotencyItemAlreadyExistsException.class) - // DataRecord should be present due to returnValuesOnConditionCheckFailure("ALL_OLD") - .matches(e -> ((IdempotencyItemAlreadyExistsException) e).getDataRecord().isPresent()); - - // THEN: item was not updated, retrieve the initial one - Map itemInDb = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item(); - assertThat(itemInDb).isNotNull(); - assertThat(itemInDb.get("status").s()).isEqualTo("INPROGRESS"); - assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry)); - assertThat(itemInDb.get("data").s()).isEqualTo("Fake Data"); + .matches(e -> !((IdempotencyItemAlreadyExistsException) e).getDataRecord().isPresent()); } @Test - void getRecord_shouldReturnExistingRecord() throws IdempotencyItemNotFoundException { - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - - // GIVEN: Insert a fake item with same id - Map item = new HashMap<>(key); - Instant now = Instant.now(); - long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond(); + void getRecord_shouldReturnDataRecord_whenItemExists() throws IdempotencyItemNotFoundException { + // GIVEN + long expiry = Instant.now().plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + Map item = new HashMap<>(); + item.put("id", AttributeValue.builder().s("key").build()); + item.put("status", AttributeValue.builder().s("COMPLETED").build()); item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); - item.put("status", AttributeValue.builder().s(DataRecord.Status.COMPLETED.toString()).build()); - item.put("data", AttributeValue.builder().s("Fake Data").build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); + item.put("data", AttributeValue.builder().s("Response Data").build()); + + GetItemResponse response = GetItemResponse.builder() + .item(item) + .build(); + when(mockClient.getItem(any(GetItemRequest.class))).thenReturn(response); // WHEN - DataRecord dr = dynamoDBPersistenceStore.getRecord("key"); + DataRecord dataRecord = persistenceStore.getRecord("key"); // THEN - assertThat(dr.getIdempotencyKey()).isEqualTo("key"); - assertThat(dr.getStatus()).isEqualTo(DataRecord.Status.COMPLETED); - assertThat(dr.getResponseData()).isEqualTo("Fake Data"); - assertThat(dr.getExpiryTimestamp()).isEqualTo(expiry); + ArgumentCaptor captor = ArgumentCaptor.forClass(GetItemRequest.class); + verify(mockClient).getItem(captor.capture()); + + GetItemRequest request = captor.getValue(); + assertThat(request.tableName()).isEqualTo(TABLE_NAME); + assertThat(request.consistentRead()).isTrue(); + assertThat(request.key()).containsEntry("id", AttributeValue.builder().s("key").build()); + + assertThat(dataRecord.getIdempotencyKey()).isEqualTo("key"); + assertThat(dataRecord.getStatus()).isEqualTo(DataRecord.Status.COMPLETED); + assertThat(dataRecord.getExpiryTimestamp()).isEqualTo(expiry); + assertThat(dataRecord.getResponseData()).isEqualTo("Response Data"); } @Test - void getRecord_shouldThrowException_whenRecordIsAbsent() { - assertThatThrownBy(() -> dynamoDBPersistenceStore.getRecord("key")) + void getRecord_shouldThrowException_whenItemDoesNotExist() { + // GIVEN + GetItemResponse response = GetItemResponse.builder().build(); + when(mockClient.getItem(any(GetItemRequest.class))).thenReturn(response); + + // WHEN / THEN + assertThatThrownBy(() -> persistenceStore.getRecord("key")) .isInstanceOf(IdempotencyItemNotFoundException.class); } @Test - void updateRecord_shouldUpdateRecord() { - // GIVEN: Insert a fake item with same id - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - Map item = new HashMap<>(key); - Instant now = Instant.now(); - long expiry = now.plus(360, ChronoUnit.SECONDS).getEpochSecond(); - item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); - item.put("status", AttributeValue.builder().s(DataRecord.Status.INPROGRESS.toString()).build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); - // enable payload validation - dynamoDBPersistenceStore.configure(IdempotencyConfig.builder().withPayloadValidationJMESPath("path").build(), - null); + void updateRecord_shouldSendCorrectUpdateItemRequest() { + // GIVEN + long expiry = Instant.now().plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + DataRecord dataRecord = new DataRecord("key", DataRecord.Status.COMPLETED, expiry, "Response", null); // WHEN - expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); - DataRecord dr = new DataRecord("key", DataRecord.Status.COMPLETED, expiry, "Fake result", "hash"); - dynamoDBPersistenceStore.updateRecord(dr); + persistenceStore.updateRecord(dataRecord); // THEN - Map itemInDb = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item(); - assertThat(itemInDb.get("status").s()).isEqualTo("COMPLETED"); - assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry)); - assertThat(itemInDb.get("data").s()).isEqualTo("Fake result"); - assertThat(itemInDb.get("validation").s()).isEqualTo("hash"); + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateItemRequest.class); + verify(mockClient).updateItem(captor.capture()); + + UpdateItemRequest request = captor.getValue(); + assertThat(request.tableName()).isEqualTo(TABLE_NAME); + assertThat(request.key()).containsEntry("id", AttributeValue.builder().s("key").build()); + assertThat(request.updateExpression()).contains("SET #response_data = :response_data"); + assertThat(request.updateExpression()).contains("#expiry = :expiry"); + assertThat(request.updateExpression()).contains("#status = :status"); + assertThat(request.expressionAttributeValues().get(":response_data").s()).isEqualTo("Response"); + assertThat(request.expressionAttributeValues().get(":status").s()).isEqualTo("COMPLETED"); } @Test - void deleteRecord_shouldDeleteRecord() { - // GIVEN: Insert a fake item with same id - key = Collections.singletonMap("id", AttributeValue.builder().s("key").build()); - Map item = new HashMap<>(key); + void updateRecord_shouldIncludeValidation_whenPayloadValidationEnabled() { + // GIVEN + persistenceStore.configure(IdempotencyConfig.builder().withPayloadValidationJMESPath("body").build(), null); + long expiry = Instant.now().plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + DataRecord dataRecord = new DataRecord("key", DataRecord.Status.COMPLETED, expiry, "Response", "hash123"); + + // WHEN + persistenceStore.updateRecord(dataRecord); + + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateItemRequest.class); + verify(mockClient).updateItem(captor.capture()); + + UpdateItemRequest request = captor.getValue(); + assertThat(request.updateExpression()).contains("#validation_key = :validation_key"); + assertThat(request.expressionAttributeValues().get(":validation_key").s()).isEqualTo("hash123"); + } + + @Test + void deleteRecord_shouldSendCorrectDeleteItemRequest() { + // WHEN + persistenceStore.deleteRecord("key"); + + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteItemRequest.class); + verify(mockClient).deleteItem(captor.capture()); + + DeleteItemRequest request = captor.getValue(); + assertThat(request.tableName()).isEqualTo(TABLE_NAME); + assertThat(request.key()).containsEntry("id", AttributeValue.builder().s("key").build()); + } + + @Test + void customAttributeNames_shouldUseCorrectAttributes() throws IdempotencyItemAlreadyExistsException { + // GIVEN + DynamoDBPersistenceStore customStore = DynamoDBPersistenceStore.builder() + .withTableName("custom_table") + .withDynamoDbClient(mockClient) + .withKeyAttr("pk") + .withSortKeyAttr("sk") + .withStaticPkValue("IDEMPOTENCY") + .withExpiryAttr("ttl") + .withStatusAttr("state") + .withDataAttr("result") + .withValidationAttr("hash") + .build(); + Instant now = Instant.now(); - long expiry = now.plus(360, ChronoUnit.SECONDS).getEpochSecond(); - item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build()); - item.put("status", AttributeValue.builder().s(DataRecord.Status.INPROGRESS.toString()).build()); - client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); - assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()).count()).isEqualTo(1); + long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + DataRecord dataRecord = new DataRecord("mykey", DataRecord.Status.INPROGRESS, expiry, null, null); // WHEN - dynamoDBPersistenceStore.deleteRecord("key"); + customStore.putRecord(dataRecord, now); // THEN - assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()).count()).isZero(); + ArgumentCaptor captor = ArgumentCaptor.forClass(PutItemRequest.class); + verify(mockClient).putItem(captor.capture()); + + PutItemRequest request = captor.getValue(); + assertThat(request.tableName()).isEqualTo("custom_table"); + assertThat(request.item()).containsEntry("pk", AttributeValue.builder().s("IDEMPOTENCY").build()); + assertThat(request.item()).containsEntry("sk", AttributeValue.builder().s("mykey").build()); + assertThat(request.item().get("state").s()).isEqualTo("INPROGRESS"); + assertThat(request.item().get("ttl").n()).isEqualTo(String.valueOf(expiry)); } @Test - void endToEndWithCustomAttrNamesAndSortKey() throws IdempotencyItemNotFoundException { - try { - client.createTable(CreateTableRequest.builder() - .tableName(TABLE_NAME_CUSTOM) - .keySchema( - KeySchemaElement.builder().keyType(KeyType.HASH).attributeName("key").build(), - KeySchemaElement.builder().keyType(KeyType.RANGE).attributeName("sortkey").build()) - .attributeDefinitions( - AttributeDefinition.builder().attributeName("key").attributeType(ScalarAttributeType.S) - .build(), - AttributeDefinition.builder().attributeName("sortkey").attributeType(ScalarAttributeType.S) - .build()) - .billingMode(BillingMode.PAY_PER_REQUEST) - .build()); - - DynamoDBPersistenceStore persistenceStore = DynamoDBPersistenceStore.builder() - .withTableName(TABLE_NAME_CUSTOM) - .withDynamoDbClient(client) - .withDataAttr("result") - .withExpiryAttr("expiry") - .withKeyAttr("key") - .withSortKeyAttr("sortkey") - .withStaticPkValue("pk") - .withStatusAttr("state") - .withValidationAttr("valid") - .build(); - - Instant now = Instant.now(); - DataRecord dr = new DataRecord( - "mykey", - DataRecord.Status.INPROGRESS, - now.plus(400, ChronoUnit.SECONDS).getEpochSecond(), - null, - null); - // PUT - persistenceStore.putRecord(dr, now); - - Map customKey = new HashMap<>(); - customKey.put("key", AttributeValue.builder().s("pk").build()); - customKey.put("sortkey", AttributeValue.builder().s("mykey").build()); - - Map itemInDb = client - .getItem(GetItemRequest.builder().tableName(TABLE_NAME_CUSTOM).key(customKey).build()).item(); - - // GET - DataRecord recordInDb = persistenceStore.getRecord("mykey"); - - assertThat(itemInDb).isNotNull(); - assertThat(itemInDb.get("key").s()).isEqualTo("pk"); - assertThat(itemInDb.get("sortkey").s()).isEqualTo(recordInDb.getIdempotencyKey()); - assertThat(itemInDb.get("state").s()).isEqualTo(recordInDb.getStatus().toString()); - assertThat(itemInDb.get("expiry").n()).isEqualTo(String.valueOf(recordInDb.getExpiryTimestamp())); - - // UPDATE - DataRecord updatedRecord = new DataRecord( - "mykey", - DataRecord.Status.COMPLETED, - now.plus(500, ChronoUnit.SECONDS).getEpochSecond(), - "response", - null); - persistenceStore.updateRecord(updatedRecord); - recordInDb = persistenceStore.getRecord("mykey"); - assertThat(recordInDb).isEqualTo(updatedRecord); - - // DELETE - persistenceStore.deleteRecord("mykey"); - assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME_CUSTOM).build()).count()).isEqualTo(0); - - } finally { - try { - client.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME_CUSTOM).build()); - } catch (Exception e) { - // OK - } - } + void customAttributeNames_shouldUseCorrectKey_forGet() throws IdempotencyItemNotFoundException { + // GIVEN + DynamoDBPersistenceStore customStore = DynamoDBPersistenceStore.builder() + .withTableName("custom_table") + .withDynamoDbClient(mockClient) + .withKeyAttr("pk") + .withSortKeyAttr("sk") + .withStaticPkValue("IDEMPOTENCY") + .withExpiryAttr("ttl") + .withStatusAttr("state") + .build(); + + long expiry = Instant.now().plus(3600, ChronoUnit.SECONDS).getEpochSecond(); + Map item = new HashMap<>(); + item.put("pk", AttributeValue.builder().s("IDEMPOTENCY").build()); + item.put("sk", AttributeValue.builder().s("mykey").build()); + item.put("state", AttributeValue.builder().s("COMPLETED").build()); + item.put("ttl", AttributeValue.builder().n(String.valueOf(expiry)).build()); + + GetItemResponse response = GetItemResponse.builder().item(item).build(); + when(mockClient.getItem(any(GetItemRequest.class))).thenReturn(response); + + // WHEN + DataRecord dataRecord = customStore.getRecord("mykey"); + + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(GetItemRequest.class); + verify(mockClient).getItem(captor.capture()); + + GetItemRequest request = captor.getValue(); + assertThat(request.key()).containsEntry("pk", AttributeValue.builder().s("IDEMPOTENCY").build()); + assertThat(request.key()).containsEntry("sk", AttributeValue.builder().s("mykey").build()); + + assertThat(dataRecord.getIdempotencyKey()).isEqualTo("mykey"); + assertThat(dataRecord.getStatus()).isEqualTo(DataRecord.Status.COMPLETED); } @Test @SetEnvironmentVariable(key = Constants.IDEMPOTENCY_DISABLED_ENV, value = "true") void idempotencyDisabled_noClientShouldBeCreated() { - DynamoDBPersistenceStore store = DynamoDBPersistenceStore.builder().withTableName(TABLE_NAME).build(); - assertThatThrownBy(() -> store.getRecord("fake")) - .isInstanceOf(NullPointerException.class); - } - - @BeforeEach - void setup() { - dynamoDBPersistenceStore = DynamoDBPersistenceStore.builder() + // GIVEN / WHEN + DynamoDBPersistenceStore store = DynamoDBPersistenceStore.builder() .withTableName(TABLE_NAME) - .withDynamoDbClient(client) .build(); - } - @AfterEach - void emptyDB() { - // Clear all items from the table - client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()) - .items() - .forEach(item -> { - Map itemKey = Collections.singletonMap("id", item.get("id")); - client.deleteItem(DeleteItemRequest.builder().tableName(TABLE_NAME).key(itemKey).build()); - }); - key = null; + // THEN + assertThatThrownBy(() -> store.getRecord("fake")) + .isInstanceOf(NullPointerException.class); } } diff --git a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/IdempotencyTest.java b/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/IdempotencyTest.java deleted file mode 100644 index e85614580..000000000 --- a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/IdempotencyTest.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2023 Amazon.com, Inc. or its affiliates. - * Licensed under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package software.amazon.lambda.powertools.idempotency.persistence.dynamodb; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.amazonaws.services.lambda.runtime.tests.EventLoader; - -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.lambda.powertools.common.stubs.TestLambdaContext; -import software.amazon.lambda.powertools.idempotency.persistence.dynamodb.handlers.IdempotencyFunction; - -class IdempotencyTest extends DynamoDBConfig { - - private Context context = new TestLambdaContext(); - - @Test - void endToEndTest() { - IdempotencyFunction function = new IdempotencyFunction(client); - - APIGatewayProxyResponseEvent response = function - .handleRequest(EventLoader.loadApiGatewayRestEvent("apigw_event2.json"), context); - assertThat(function.handlerExecuted).isTrue(); - - function.handlerExecuted = false; - - APIGatewayProxyResponseEvent response2 = function - .handleRequest(EventLoader.loadApiGatewayRestEvent("apigw_event2.json"), context); - assertThat(function.handlerExecuted).isFalse(); - - assertThat(response).isEqualTo(response2); - assertThat(response2.getBody()).contains("hello world"); - - assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()).count()).isEqualTo(1); - } -} diff --git a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/handlers/IdempotencyFunction.java b/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/handlers/IdempotencyFunction.java deleted file mode 100644 index d816af801..000000000 --- a/powertools-idempotency/powertools-idempotency-dynamodb/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/dynamodb/handlers/IdempotencyFunction.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2023 Amazon.com, Inc. or its affiliates. - * Licensed under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package software.amazon.lambda.powertools.idempotency.persistence.dynamodb.handlers; - -import java.util.HashMap; -import java.util.Map; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; - -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.lambda.powertools.idempotency.Idempotency; -import software.amazon.lambda.powertools.idempotency.IdempotencyConfig; -import software.amazon.lambda.powertools.idempotency.Idempotent; -import software.amazon.lambda.powertools.idempotency.persistence.dynamodb.DynamoDBPersistenceStore; - -public class IdempotencyFunction implements RequestHandler { - public boolean handlerExecuted = false; - - public IdempotencyFunction(DynamoDbClient client) { - // we need to initialize idempotency configuration before the handleRequest method is called - Idempotency.config().withConfig( - IdempotencyConfig.builder() - .withEventKeyJMESPath("powertools_json(body).address") - .build()) - .withPersistenceStore( - DynamoDBPersistenceStore.builder() - .withTableName("idempotency_table") - .withDynamoDbClient(client) - .build()) - .configure(); - } - - @Idempotent - public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { - handlerExecuted = true; - Map headers = new HashMap<>(); - - headers.put("Content-Type", "application/json"); - headers.put("Access-Control-Allow-Origin", "*"); - headers.put("Access-Control-Allow-Methods", "GET, OPTIONS"); - headers.put("Access-Control-Allow-Headers", "*"); - - APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent() - .withHeaders(headers); - - return response - .withStatusCode(200) - .withBody("{ \"message\": \"hello world\"}"); - - } -}