Convert storage-aws tests to Java (#484)

* storage-aws.ListerTest: convert to Java

* storage-aws: convert to Java
This commit is contained in:
Paul Campbell 2020-06-26 08:01:29 +01:00 committed by GitHub
parent 5661c9e7da
commit 78b1bcf6ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 510 additions and 602 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 193 KiB

View file

@ -1,6 +1,7 @@
package net.kemitix.thorp.domain; package net.kemitix.thorp.domain;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -27,6 +28,7 @@ public class StorageEvent {
public static class DoNothingEvent extends StorageEvent { public static class DoNothingEvent extends StorageEvent {
public final RemoteKey remoteKey; public final RemoteKey remoteKey;
} }
@EqualsAndHashCode(callSuper = false)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE) @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class CopyEvent extends StorageEvent { public static class CopyEvent extends StorageEvent {
public final RemoteKey sourceKey; public final RemoteKey sourceKey;
@ -37,6 +39,7 @@ public class StorageEvent {
public final RemoteKey remoteKey; public final RemoteKey remoteKey;
public final MD5Hash md5Hash; public final MD5Hash md5Hash;
} }
@EqualsAndHashCode(callSuper = false)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE) @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class DeleteEvent extends StorageEvent { public static class DeleteEvent extends StorageEvent {
public final RemoteKey remoteKey; public final RemoteKey remoteKey;

View file

@ -13,7 +13,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
public class MD5HashGenerator implements HashGenerator { public class MD5HashGenerator implements HashGenerator {
private static final int maxBufferSize = 8048; private static final int maxBufferSize = 8192;
private static final byte[] defaultBuffer = new byte[maxBufferSize]; private static final byte[] defaultBuffer = new byte[maxBufferSize];
public static String hex(byte[] in) throws NoSuchAlgorithmException { public static String hex(byte[] in) throws NoSuchAlgorithmException {
MessageDigest md5 = MessageDigest.getInstance("MD5"); MessageDigest md5 = MessageDigest.getInstance("MD5");

View file

@ -2,6 +2,7 @@ package net.kemitix.thorp.lib
import net.kemitix.thorp.config.Configuration import net.kemitix.thorp.config.Configuration
import net.kemitix.thorp.domain.Action.{ToCopy, ToDelete, ToUpload} import net.kemitix.thorp.domain.Action.{ToCopy, ToDelete, ToUpload}
import net.kemitix.thorp.domain.StorageEvent.ActionSummary
import net.kemitix.thorp.domain._ import net.kemitix.thorp.domain._
import net.kemitix.thorp.storage.Storage import net.kemitix.thorp.storage.Storage
import net.kemitix.thorp.uishell.{UIEvent, UploadEventListener} import net.kemitix.thorp.uishell.{UIEvent, UploadEventListener}
@ -35,7 +36,16 @@ trait UnversionedMirrorArchive extends ThorpArchive {
.copy(bucket, sourceKey, hash, targetKey) .copy(bucket, sourceKey, hash, targetKey)
case toDelete: ToDelete => case toDelete: ToDelete =>
val remoteKey = toDelete.remoteKey val remoteKey = toDelete.remoteKey
try {
Storage.getInstance().delete(bucket, remoteKey) Storage.getInstance().delete(bucket, remoteKey)
} catch {
case e: Throwable =>
StorageEvent.errorEvent(
ActionSummary.delete(remoteKey.key()),
remoteKey,
e
);
}
case doNothing: Action.DoNothing => case doNothing: Action.DoNothing =>
val remoteKey = doNothing.remoteKey val remoteKey = doNothing.remoteKey
StorageEvent.doNothingEvent(remoteKey) StorageEvent.doNothingEvent(remoteKey)

View file

@ -48,11 +48,10 @@
<artifactId>assertj-core</artifactId> <artifactId>assertj-core</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- scala -->
<dependency> <dependency>
<groupId>org.scala-lang</groupId> <groupId>org.mockito</groupId>
<artifactId>scala-library</artifactId> <artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency> </dependency>
<!-- aws-sdk --> <!-- aws-sdk -->
@ -95,27 +94,5 @@
<groupId>commons-logging</groupId> <groupId>commons-logging</groupId>
<version>1.2</version> <version>1.2</version>
</dependency> </dependency>
<!-- scala - testing -->
<dependency>
<groupId>org.scalatest</groupId>
<artifactId>scalatest_2.13</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.scalamock</groupId>
<artifactId>scalamock_2.13</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build>
<plugins>
<plugin>
<groupId>net.alchim31.maven</groupId>
<artifactId>scala-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project> </project>

View file

@ -1,5 +1,6 @@
package net.kemitix.thorp.storage.aws; package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.CopyObjectRequest; import com.amazonaws.services.s3.model.CopyObjectRequest;
import net.kemitix.thorp.domain.Bucket; import net.kemitix.thorp.domain.Bucket;
import net.kemitix.thorp.domain.MD5Hash; import net.kemitix.thorp.domain.MD5Hash;
@ -24,14 +25,17 @@ public interface S3Copier {
return request -> { return request -> {
RemoteKey sourceKey = RemoteKey.create(request.getSourceKey()); RemoteKey sourceKey = RemoteKey.create(request.getSourceKey());
RemoteKey targetKey = RemoteKey.create(request.getDestinationKey()); RemoteKey targetKey = RemoteKey.create(request.getDestinationKey());
try {
return client.copyObject(request) return client.copyObject(request)
.map(success -> StorageEvent.copyEvent(sourceKey, targetKey)) .map(success -> StorageEvent.copyEvent(sourceKey, targetKey))
.orElseGet(() -> errorEvent(sourceKey, targetKey)); .orElseGet(() -> StorageEvent.errorEvent(
}; actionSummary(sourceKey, targetKey),
targetKey, S3Exception.hashError()));
} catch (SdkClientException e) {
return StorageEvent.errorEvent(
actionSummary(sourceKey, targetKey), targetKey, e);
} }
};
static StorageEvent.ErrorEvent errorEvent(RemoteKey sourceKey, RemoteKey targetKey) {
return StorageEvent.errorEvent(actionSummary(sourceKey, targetKey), targetKey, S3Exception.hashError());
} }
static StorageEvent.ActionSummary.Copy actionSummary(RemoteKey sourceKey, RemoteKey targetKey) { static StorageEvent.ActionSummary.Copy actionSummary(RemoteKey sourceKey, RemoteKey targetKey) {

View file

@ -1,5 +1,6 @@
package net.kemitix.thorp.storage.aws; package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.DeleteObjectRequest; import com.amazonaws.services.s3.model.DeleteObjectRequest;
import net.kemitix.thorp.domain.Bucket; import net.kemitix.thorp.domain.Bucket;
import net.kemitix.thorp.domain.RemoteKey; import net.kemitix.thorp.domain.RemoteKey;
@ -13,9 +14,15 @@ public interface S3Deleter {
} }
static Function<DeleteObjectRequest, StorageEvent> deleter(AmazonS3Client client) { static Function<DeleteObjectRequest, StorageEvent> deleter(AmazonS3Client client) {
return request -> { return request -> {
client.deleteObject(request);
RemoteKey remoteKey = RemoteKey.create(request.getKey()); RemoteKey remoteKey = RemoteKey.create(request.getKey());
try {
client.deleteObject(request);
return StorageEvent.deleteEvent(remoteKey); return StorageEvent.deleteEvent(remoteKey);
} catch (SdkClientException e) {
return StorageEvent.errorEvent(
StorageEvent.ActionSummary.delete(remoteKey.key()),
remoteKey, e);
}
}; };
} }
} }

View file

@ -7,15 +7,18 @@ import net.kemitix.thorp.domain.HashGenerator;
import net.kemitix.thorp.domain.HashType; import net.kemitix.thorp.domain.HashType;
import net.kemitix.thorp.domain.Hashes; import net.kemitix.thorp.domain.Hashes;
import net.kemitix.thorp.domain.MD5Hash; import net.kemitix.thorp.domain.MD5Hash;
import net.kemitix.thorp.filesystem.MD5HashGenerator;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.LongStream; import java.util.stream.LongStream;
import static net.kemitix.thorp.filesystem.MD5HashGenerator.md5FileChunk;
public class S3ETagGenerator implements HashGenerator { public class S3ETagGenerator implements HashGenerator {
@Deprecated // Use hashFile @Deprecated // Use hashFile
public String eTag(Path path) throws IOException, NoSuchAlgorithmException { public String eTag(Path path) throws IOException, NoSuchAlgorithmException {
@ -43,7 +46,7 @@ public class S3ETagGenerator implements HashGenerator {
public List<Long> offsets(long totalFileSizeBytes, long optimalPartSize) { public List<Long> offsets(long totalFileSizeBytes, long optimalPartSize) {
return LongStream return LongStream
.range(0, totalFileSizeBytes / optimalPartSize) .rangeClosed(0, totalFileSizeBytes / optimalPartSize)
.mapToObj(part -> part * optimalPartSize) .mapToObj(part -> part * optimalPartSize)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@ -63,12 +66,11 @@ public class S3ETagGenerator implements HashGenerator {
} }
private String eTagHex(Path path, long partSize, long parts) throws IOException, NoSuchAlgorithmException { private String eTagHex(Path path, long partSize, long parts) throws IOException, NoSuchAlgorithmException {
HashGenerator hashGenerator = HashGenerator.generatorFor("MD5"); ByteArrayOutputStream bos = new ByteArrayOutputStream();
MessageDigest md5 = MessageDigest.getInstance("MD5");
for (long i = 0; i < parts ; i++ ){ for (long i = 0; i < parts ; i++ ){
md5.update(hashGenerator.hashChunk(path, i, partSize).digest()); bos.write(md5FileChunk(path, i * partSize, partSize).digest());
} }
return MD5Hash.digestAsString(md5.digest()); return MD5HashGenerator.hex(bos.toByteArray());
} }
@Override @Override
public HashType hashType() { public HashType hashType() {

View file

@ -1,5 +1,7 @@
package net.kemitix.thorp.storage.aws; package net.kemitix.thorp.storage.aws;
import com.amazonaws.AmazonServiceException;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.ListObjectsV2Request; import com.amazonaws.services.s3.model.ListObjectsV2Request;
import com.amazonaws.services.s3.model.ListObjectsV2Result; import com.amazonaws.services.s3.model.ListObjectsV2Result;
import com.amazonaws.services.s3.model.S3ObjectSummary; import com.amazonaws.services.s3.model.S3ObjectSummary;
@ -71,10 +73,14 @@ public interface S3Lister {
AmazonS3Client client, AmazonS3Client client,
ListObjectsV2Request request ListObjectsV2Request request
) { ) {
try {
Batch batch = fetchBatch(client, request); Batch batch = fetchBatch(client, request);
List<S3ObjectSummary> more = fetchMore(client, request, batch.more); List<S3ObjectSummary> more = fetchMore(client, request, batch.more);
batch.summaries.addAll(more); batch.summaries.addAll(more);
return batch.summaries; return batch.summaries;
} catch (SdkClientException e) {
return Collections.emptyList();
}
}; };
static Optional<String> moreToken(ListObjectsV2Result result) { static Optional<String> moreToken(ListObjectsV2Result result) {

View file

@ -2,7 +2,6 @@ package net.kemitix.thorp.storage.aws;
import com.amazonaws.services.s3.model.PutObjectRequest; import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.services.s3.transfer.TransferManager; import com.amazonaws.services.s3.transfer.TransferManager;
import com.amazonaws.services.s3.transfer.Upload;
import java.util.function.Function; import java.util.function.Function;
@ -17,14 +16,9 @@ public interface S3TransferManager {
} }
@Override @Override
public Function<PutObjectRequest, S3Upload> uploader() { public Function<PutObjectRequest, S3Upload> uploader() {
return request -> { return request ->
Upload upload = transferManager.upload(request); S3Upload.inProgress(
try { transferManager.upload(request));
return S3Upload.inProgress(upload);
} catch (S3Exception.UploadError error) {
return S3Upload.errored(error);
}
};
} }
}; };
} }

View file

@ -1,5 +1,6 @@
package net.kemitix.thorp.storage.aws; package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest; import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.services.s3.transfer.model.UploadResult; import com.amazonaws.services.s3.transfer.model.UploadResult;
@ -29,6 +30,7 @@ public interface S3Uploader {
S3TransferManager transferManager S3TransferManager transferManager
) { ) {
return request -> { return request -> {
try {
UploadResult uploadResult = UploadResult uploadResult =
transferManager.uploader() transferManager.uploader()
.apply(request) .apply(request)
@ -36,6 +38,12 @@ public interface S3Uploader {
return StorageEvent.uploadEvent( return StorageEvent.uploadEvent(
RemoteKey.create(uploadResult.getKey()), RemoteKey.create(uploadResult.getKey()),
MD5Hash.create(uploadResult.getETag())); MD5Hash.create(uploadResult.getETag()));
} catch (SdkClientException e) {
String key = request.getKey();
return StorageEvent.errorEvent(
StorageEvent.ActionSummary.upload(key),
RemoteKey.create(key), e);
}
}; };
} }
} }

View file

@ -0,0 +1,65 @@
package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.CopyObjectRequest;
import com.amazonaws.services.s3.model.CopyObjectResult;
import net.kemitix.thorp.domain.Bucket;
import net.kemitix.thorp.domain.MD5Hash;
import net.kemitix.thorp.domain.RemoteKey;
import net.kemitix.thorp.domain.StorageEvent;
import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
@ExtendWith(MockitoExtension.class)
public class CopierTest
implements WithAssertions {
private final Bucket bucket = Bucket.named("aBucket");
private final RemoteKey sourceKey = RemoteKey.create("sourceKey");
private final MD5Hash hash = MD5Hash.create("aHash");
private final RemoteKey targetKey = RemoteKey.create("targetKey");
private final CopyObjectRequest request = S3Copier.request(bucket, sourceKey, hash, targetKey);
private final AmazonS3Client amazonS3Client;
public CopierTest(@Mock AmazonS3Client amazonS3Client) {
this.amazonS3Client = amazonS3Client;
}
@Test
@DisplayName("copy produces copy event")
public void copy_thenCopyEvent() {
//given
StorageEvent event = StorageEvent.copyEvent(sourceKey, targetKey);
given(amazonS3Client.copyObject(request))
.willReturn(Optional.of(new CopyObjectResult()));
//when
StorageEvent result = S3Copier.copier(amazonS3Client).apply(request);
//then
assertThat(result).isEqualTo(event);
}
@Test
@DisplayName("when error copying then return an error storage event")
public void whenCopyErrors_thenErrorEvent() {
//given
doThrow(SdkClientException.class)
.when(amazonS3Client)
.copyObject(request);
//when
StorageEvent result = S3Copier.copier(amazonS3Client).apply(request);
//then
assertThat(result).isInstanceOf(StorageEvent.ErrorEvent.class);
}
}

View file

@ -0,0 +1,52 @@
package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import net.kemitix.thorp.domain.Bucket;
import net.kemitix.thorp.domain.RemoteKey;
import net.kemitix.thorp.domain.StorageEvent;
import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.doThrow;
@ExtendWith(MockitoExtension.class)
public class DeleterTest
implements WithAssertions {
private final Bucket bucket = Bucket.named("aBucket");
private final RemoteKey remoteKey = RemoteKey.create("aRemoteKey");
private final DeleteObjectRequest request = S3Deleter.request(bucket, remoteKey);
private final AmazonS3Client amazonS3Client;
public DeleterTest(@Mock AmazonS3Client amazonS3Client) {
this.amazonS3Client = amazonS3Client;
}
@Test
@DisplayName("when delete then return delete storage event")
public void whenDelete_thenDeleteEvent() {
//when
StorageEvent result = S3Deleter.deleter(amazonS3Client).apply(request);
//then
assertThat(result).isEqualTo(StorageEvent.deleteEvent(remoteKey));
}
@Test
@DisplayName("when error deleting then return an error storage event")
public void whenDeleteErrors_thenErrorEvent() {
//given
doThrow(SdkClientException.class)
.when(amazonS3Client)
.deleteObject(request);
//when
StorageEvent result = S3Deleter.deleter(amazonS3Client).apply(request);
//then
assertThat(result).isInstanceOf(StorageEvent.ErrorEvent.class);
}
}

View file

@ -0,0 +1,54 @@
package net.kemitix.thorp.storage.aws;
import net.kemitix.thorp.domain.MD5Hash;
import net.kemitix.thorp.domain.Tuple;
import net.kemitix.thorp.filesystem.Resource;
import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
public class ETagGeneratorTest
implements WithAssertions {
public static final String BIG_FILE_ETAG = "f14327c90ad105244c446c498bfe9a7d-2";
private final Resource bigFile = Resource.select(this, "big-file");
private final long chunkSize = 1200000;
private final S3ETagGenerator generator = new S3ETagGenerator();
@Test
@DisplayName("creates offsets")
public void createsOffsets() {
List<Long> offsets = generator.offsets(bigFile.length(), chunkSize);
assertThat(offsets).containsExactly(
0L, chunkSize, chunkSize * 2, chunkSize * 3, chunkSize * 4);
}
@Test
@DisplayName("generate valid hashes")
public void generatesValidHashes() throws IOException, NoSuchAlgorithmException {
List<Tuple<Integer, String>> md5Hashes = Arrays.asList(
Tuple.create(0, "68b7d37e6578297621e06f01800204f1"),
Tuple.create(1, "973475b14a7bda6ad8864a7f9913a947"),
Tuple.create(2, "b9adcfc5b103fe2dd5924a5e5e6817f0"),
Tuple.create(3, "5bd6e10a99fef100fe7bf5eaa0a42384"),
Tuple.create(4, "8a0c1d0778ac8fcf4ca2010eba4711eb"));
for (Tuple<Integer, String> t : md5Hashes) {
long offset = t.a * chunkSize;
MD5Hash md5Hash =
generator.hashChunk(bigFile.toPath(), offset, chunkSize);
assertThat(md5Hash.getValue()).isEqualTo(t.b);
}
}
@Test
@DisplayName("create eTag for whole file")
public void createTagForWholeFile() throws IOException, NoSuchAlgorithmException {
String result = generator.hashFile(bigFile.toPath());
assertThat(result).isEqualTo(BIG_FILE_ETAG);
}
}

View file

@ -42,7 +42,8 @@ public class HashGeneratorTest
//when //when
Hashes result = HashGenerator.hashObject(path); Hashes result = HashGenerator.hashObject(path);
//then //then
assertThat(result.get(HashType.MD5)).contains(MD5HashData.rootHash()); assertThat(result.get(HashType.MD5))
.contains(MD5Hash.create("a3a6ac11a0eb577b81b3bb5c95cc8a6e"));
} }
@Test @Test
@DisplayName("leaf-file") @DisplayName("leaf-file")
@ -52,7 +53,8 @@ public class HashGeneratorTest
//when //when
Hashes result = HashGenerator.hashObject(path); Hashes result = HashGenerator.hashObject(path);
//then //then
assertThat(result.get(HashType.MD5)).contains(MD5HashData.leafHash()); assertThat(result.get(HashType.MD5))
.contains(MD5Hash.create("208386a650bdec61cfcd7bd8dcb6b542"));
} }
private Path getResource(String s) { private Path getResource(String s) {

View file

@ -0,0 +1,136 @@
package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.ListObjectsV2Request;
import com.amazonaws.services.s3.model.ListObjectsV2Result;
import com.amazonaws.services.s3.model.S3ObjectSummary;
import net.kemitix.thorp.domain.*;
import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
@ExtendWith(MockitoExtension.class)
public class ListerTest
implements WithAssertions {
private final Bucket bucket = Bucket.named("aBucket");
private final RemoteKey prefix = RemoteKey.create("aRemoteKey");
private final ListObjectsV2Request request = S3Lister.request(bucket, prefix);
private final AmazonS3Client amazonS3Client;
public ListerTest(@Mock AmazonS3Client amazonS3Client) {
this.amazonS3Client = amazonS3Client;
}
@Test
@DisplayName("single fetch")
public void singleFetch() {
//given
Date nowDate = new Date();
String key = "key";
String etag = "etag";
Map<MD5Hash, RemoteKey> expectedHashMap =
Collections.singletonMap(
MD5Hash.create(etag),
RemoteKey.create(key)
);
Map<RemoteKey, MD5Hash> expectedKeyMap =
Collections.singletonMap(
RemoteKey.create(key),
MD5Hash.create(etag)
);
given(amazonS3Client.listObjects(any()))
.willReturn(objectResults(nowDate, key, etag, false));
//when
RemoteObjects result = S3Lister.lister(amazonS3Client).apply(request);
//then
assertThat(result.byHash.asMap()).isEqualTo(expectedHashMap);
assertThat(result.byKey.asMap()).isEqualTo(expectedKeyMap);
}
@Test
@DisplayName("two fetches")
public void twoFetches() {
//given
Date nowDate = new Date();
String key1 = "key1";
String etag1 = "etag1";
String key2 = "key2";
String etag2 = "etag2";
Map<MD5Hash, RemoteKey> expectedHashMap = new HashMap<>();
expectedHashMap.put(
MD5Hash.create(etag1),
RemoteKey.create(key1));
expectedHashMap.put(
MD5Hash.create(etag2),
RemoteKey.create(key2));
Map<RemoteKey, MD5Hash> expectedKeyMap = new HashMap<>();
expectedKeyMap.put(
RemoteKey.create(key1),
MD5Hash.create(etag1));
expectedKeyMap.put(
RemoteKey.create(key2),
MD5Hash.create(etag2));
given(amazonS3Client.listObjects(any()))
.willReturn(objectResults(nowDate, key1, etag1, true))
.willReturn(objectResults(nowDate, key2, etag2, false));
//when
RemoteObjects result = S3Lister.lister(amazonS3Client).apply(request);
//then
assertThat(result.byHash.asMap()).isEqualTo(expectedHashMap);
assertThat(result.byKey.asMap()).isEqualTo(expectedKeyMap);
}
@Test
@DisplayName("when error listing then return an error storage event")
public void whenListErrors_thenErrorEvent() {
//given
doThrow(SdkClientException.class)
.when(amazonS3Client)
.listObjects(request);
//when
RemoteObjects result = S3Lister.lister(amazonS3Client).apply(request);
//then
assertThat(result.byKey.asMap()).isEmpty();
assertThat(result.byHash.asMap()).isEmpty();
}
private ListObjectsV2Result objectResults(
Date nowDate,
String key,
String etag,
boolean truncated
) {
ListObjectsV2Result result = new ListObjectsV2Result();
result.getObjectSummaries().add(objectSummary(key, etag, nowDate));
result.setNextContinuationToken("next token");
result.setTruncated(truncated);
return result;
}
private S3ObjectSummary objectSummary(
String key,
String etag,
Date lastModified
) {
S3ObjectSummary summary = new S3ObjectSummary();
summary.setKey(key);
summary.setETag(etag);
summary.setLastModified(lastModified);
return summary;
}
}

View file

@ -0,0 +1,44 @@
package net.kemitix.thorp.storage.aws;
import com.amazonaws.services.s3.model.S3ObjectSummary;
import lombok.val;
import net.kemitix.thorp.domain.MD5Hash;
import net.kemitix.thorp.domain.RemoteKey;
import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class ObjectsByHashTest
implements WithAssertions {
private S3ObjectSummary s3object(MD5Hash md5Hash,
RemoteKey remoteKey) {
S3ObjectSummary summary = new S3ObjectSummary();
summary.setETag(md5Hash.hash());
summary.setKey(remoteKey.key());
return summary;
}
@Test
@DisplayName("grouping s3 objects together by their hash value")
public void groupS3ObjectsByHashValue() {
//given
MD5Hash hash = MD5Hash.create("hash");
RemoteKey key1 = RemoteKey.create("key-1");
RemoteKey key2 = RemoteKey.create("key-2");
S3ObjectSummary o1 = s3object(hash, key1);
S3ObjectSummary o2 = s3object(hash, key2);
List<S3ObjectSummary> os = Arrays.asList(o1, o2);
Map<MD5Hash, RemoteKey> expected = Collections.singletonMap(
hash, key2);
//when
Map<MD5Hash, RemoteKey> result = S3Lister.byHash(os);
//then
assertThat(result).isEqualTo(expected);
}
}

View file

@ -0,0 +1,81 @@
package net.kemitix.thorp.storage.aws;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.services.s3.transfer.TransferManager;
import com.amazonaws.services.s3.transfer.Upload;
import com.amazonaws.services.s3.transfer.model.UploadResult;
import net.kemitix.thorp.domain.HashType;
import net.kemitix.thorp.domain.*;
import net.kemitix.thorp.filesystem.Resource;
import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.File;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
@ExtendWith(MockitoExtension.class)
public class UploaderTest
implements WithAssertions {
private final File aSource = Resource.select(this, "").toFile();
private final File aFile = Resource.select(this, "small-file").toFile();
private final MD5Hash aHash = MD5Hash.create("aHash");
private final Hashes hashes = Hashes.create(HashType.MD5, aHash);
private final RemoteKey remoteKey = RemoteKey.create("aRemoteKey");
private final LocalFile localFile =
LocalFile.create(aFile, aSource, hashes, remoteKey, aFile.length());
private final Bucket bucket = Bucket.named("aBucket");
private final PutObjectRequest request =
S3Uploader.request(localFile, bucket);
private final TransferManager transferManager;
private final S3TransferManager s3TransferManager;
private final UploadResult uploadResult;
private final Upload upload;
public UploaderTest(@Mock TransferManager transferManager,
@Mock Upload upload) {
this.transferManager = transferManager;
this.upload = upload;
s3TransferManager = S3TransferManager.create(transferManager);
uploadResult = new UploadResult();
uploadResult.setKey(remoteKey.key());
uploadResult.setETag(aHash.hash());
}
@Test
@DisplayName("when upload then return upload event")
public void whenUpload_thenUploadEvent() throws InterruptedException {
//given
given(transferManager.upload(request))
.willReturn(upload);
given(upload.waitForUploadResult()).willReturn(uploadResult);
//when
StorageEvent result = S3Uploader.uploader(s3TransferManager).apply(request);
//then
assertThat(result).isInstanceOf(StorageEvent.UploadEvent.class);
}
@Test
@DisplayName("when error uploading then return an error storage event")
public void whenUploadErrors_thenErrorEvent() {
//given
doThrow(SdkClientException.class)
.when(transferManager)
.upload(request);
//when
StorageEvent result = S3Uploader.uploader(s3TransferManager).apply(request);
//then
assertThat(result).isInstanceOf(StorageEvent.ErrorEvent.class);
}
}

View file

@ -1,60 +0,0 @@
package net.kemitix.thorp.storage.aws
import net.kemitix.thorp.storage.Storage
import org.scalamock.scalatest.MockFactory
trait AmazonS3ClientTestFixture extends MockFactory {
@SuppressWarnings(Array("org.wartremover.warts.PublicInference"))
private val manager = stub[S3TransferManager]
@SuppressWarnings(Array("org.wartremover.warts.PublicInference"))
private val client = stub[AmazonS3Client]
val fixture: Fixture = Fixture(client, manager)
case class Fixture(amazonS3Client: AmazonS3Client,
amazonS3TransferManager: S3TransferManager,
) {
lazy val storageService: Storage = Storage.getInstance()
// new Storage.Service {
//
// private val client = amazonS3Client
// private val transferManager = amazonS3TransferManager
//
// override def listObjects(
// bucket: Bucket,
// prefix: RemoteKey
// ): RIO[Storage, RemoteObjects] =
// UIO {
// S3Lister.lister(client)(S3Lister.request(bucket, prefix))
// }
//
// override def upload(localFile: LocalFile,
// bucket: Bucket,
// listenerSettings: UploadEventListener.Settings,
// ): UIO[StorageEvent] =
// UIO(
// S3Uploader
// .uploader(transferManager)(S3Uploader.request(localFile, bucket))
// )
//
// override def copy(bucket: Bucket,
// sourceKey: RemoteKey,
// hash: MD5Hash,
// targetKey: RemoteKey): UIO[StorageEvent] =
// UIO {
// val request = S3Copier.request(bucket, sourceKey, hash, targetKey)
// S3Copier.copier(client)(request)
// }
//
// override def delete(bucket: Bucket,
// remoteKey: RemoteKey): UIO[StorageEvent] =
// UIO(S3Deleter.deleter(client)(S3Deleter.request(bucket, remoteKey)))
//
// override def shutdown: UIO[StorageEvent] = {
// UIO(transferManager.shutdownNow(true)) *> UIO(client.shutdown())
// .map(_ => StorageEvent.shutdownEvent())
// }
// }
}
}

View file

@ -1,88 +0,0 @@
package net.kemitix.thorp.storage.aws
import org.scalatest.FreeSpec
class CopierTest extends FreeSpec {
// private val runtime = Runtime(Console.Live, PlatformLive.Default)
//
// "copier" - {
// val bucket = Bucket.named("aBucket")
// val sourceKey = RemoteKey.create("sourceKey")
// val hash = MD5Hash.create("aHash")
// val targetKey = RemoteKey.create("targetKey")
// "when source exists" - {
// "when source hash matches" - {
// "copies from source to target" in {
// val event = StorageEvent.copyEvent(sourceKey, targetKey)
// val expected = Right(event)
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.copyObject)
// .when()
// .returns(_ => Task.succeed(Some(new CopyObjectResult)))
// private val result =
// invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client)
// assertResult(expected)(result)
// }
// }
// }
// "when source hash does not match" - {
// "skip the file with an error" in {
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.copyObject)
// .when()
// .returns(_ => Task.succeed(None))
// private val result =
// invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client)
// result match {
// case right: Right[Throwable, StorageEvent] => {
// val e = right.value.asInstanceOf[ErrorEvent].e
// e match {
// case HashError => assert(true)
// case _ => fail(s"Not a HashError: ${e.getMessage}")
// }
// }
// case e => fail(s"Not an ErrorQueueEvent: $e")
// }
// }
// }
// }
// "when client throws an exception" - {
// "skip the file with an error" in {
// new AmazonS3ClientTestFixture {
// private val expectedMessage = "The specified key does not exist"
// (() => fixture.amazonS3Client.copyObject)
// .when()
// .returns(_ => Task.fail(new AmazonS3Exception(expectedMessage)))
// private val result =
// invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client)
// val key = RemoteKey.create("targetKey")
// result match {
// case right: Right[Throwable, StorageEvent] => {
// val e = right.value.asInstanceOf[ErrorEvent].e
// e match {
// case CopyError(cause) =>
// assert(cause.getMessage.startsWith(expectedMessage))
// case _ => fail(s"Not a CopyError: ${e.getMessage}")
// }
// }
// case e => fail(s"Not an ErrorQueueEvent: ${e}")
// }
// }
// }
// }
// }
// def invoke(
// bucket: Bucket,
// sourceKey: RemoteKey,
// hash: MD5Hash,
// targetKey: RemoteKey,
// amazonS3Client: AmazonS3Client
// ) =
// runtime.unsafeRunSync {
// Copier.copy(amazonS3Client)(
// Copier.Request(bucket, sourceKey, hash, targetKey))
// }.toEither
// }
}

View file

@ -1,59 +0,0 @@
package net.kemitix.thorp.storage.aws
import org.scalatest.FreeSpec
class DeleterTest extends FreeSpec {
// private val runtime = Runtime(Console.Live, PlatformLive.Default)
//
// "delete" - {
// val bucket = Bucket.named("aBucket")
// val remoteKey = RemoteKey.create("aRemoteKey")
// "when no errors" in {
// val expected = Right(StorageEvent.deleteEvent(remoteKey))
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.deleteObject)
// .when()
// .returns(_ => UIO.succeed(()))
// private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey)
// assertResult(expected)(result)
// }
// }
// "when Amazon Service Exception" in {
// val exception = new AmazonS3Exception("message")
// val expected =
// Right(
// StorageEvent.errorEvent(ActionSummary.delete(remoteKey.key),
// remoteKey,
// exception))
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.deleteObject)
// .when()
// .returns(_ => Task.fail(exception))
// private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey)
// assertResult(expected)(result)
// }
// }
// "when Amazon SDK Client Exception" in {
// val exception = new SdkClientException("message")
// val expected =
// Right(
// StorageEvent.errorEvent(ActionSummary.delete(remoteKey.key),
// remoteKey,
// exception))
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.deleteObject)
// .when()
// .returns(_ => Task.fail(exception))
// private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey)
// assertResult(expected)(result)
// }
// }
// def invoke(amazonS3Client: AmazonS3Client.Client)(bucket: Bucket,
// remoteKey: RemoteKey) =
// runtime.unsafeRunSync {
// Deleter.delete(amazonS3Client)(bucket, remoteKey)
// }.toEither
//
// }
}

View file

@ -1,119 +0,0 @@
package net.kemitix.thorp.storage.aws
import org.scalatest.FreeSpec
class ListerTest extends FreeSpec {
// "list" - {
// val bucket = Bucket.named("aBucket")
// val prefix = RemoteKey.create("aRemoteKey")
// "when no errors" - {
// "when single fetch required" in {
// val nowDate = new Date
// val key = "key"
// val etag = "etag"
// val expectedHashMap = Map(MD5Hash.create(etag) -> RemoteKey.create(key))
// val expectedKeyMap = Map(RemoteKey.create(key) -> MD5Hash.create(etag))
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.listObjectsV2)
// .when()
// .returns(_ => {
// UIO.succeed(objectResults(nowDate, key, etag, truncated = false))
// })
// private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
// private val hashMap =
// result.map(_.byHash).map(m => Map.from(m.asMap.asScala))
// private val keyMap =
// result.map(_.byKey).map(m => Map.from(m.asMap.asScala))
// hashMap should be(Right(expectedHashMap))
// keyMap should be(Right(expectedKeyMap))
// }
// }
//
// "when second fetch required" in {
// val nowDate = new Date
// val key1 = "key1"
// val etag1 = "etag1"
// val key2 = "key2"
// val etag2 = "etag2"
// val expectedHashMap = Map(
// MD5Hash.create(etag1) -> RemoteKey.create(key1),
// MD5Hash.create(etag2) -> RemoteKey.create(key2)
// )
// val expectedKeyMap = Map(
// RemoteKey.create(key1) -> MD5Hash.create(etag1),
// RemoteKey.create(key2) -> MD5Hash.create(etag2)
// )
// new AmazonS3ClientTestFixture {
//
// (() => fixture.amazonS3Client.listObjectsV2)
// .when()
// .returns(_ =>
// UIO(objectResults(nowDate, key1, etag1, truncated = true)))
// .noMoreThanOnce()
//
// (() => fixture.amazonS3Client.listObjectsV2)
// .when()
// .returns(_ =>
// UIO(objectResults(nowDate, key2, etag2, truncated = false)))
// private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
// private val hashMap =
// result.map(_.byHash).map(m => Map.from(m.asMap.asScala))
// private val keyMap =
// result.map(_.byKey).map(m => Map.from(m.asMap.asScala))
// hashMap should be(Right(expectedHashMap))
// keyMap should be(Right(expectedKeyMap))
// }
// }
//
// def objectSummary(key: String, etag: String, lastModified: Date) = {
// val objectSummary = new S3ObjectSummary
// objectSummary.setKey(key)
// objectSummary.setETag(etag)
// objectSummary.setLastModified(lastModified)
// objectSummary
// }
//
// def objectResults(nowDate: Date,
// key: String,
// etag: String,
// truncated: Boolean) = {
// val result = new ListObjectsV2Result
// result.getObjectSummaries.add(objectSummary(key, etag, nowDate))
// result.setTruncated(truncated)
// result
// }
//
// }
// "when Amazon Service Exception" in {
// val exception = new AmazonS3Exception("message")
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.listObjectsV2)
// .when()
// .returns(_ => Task.fail(exception))
// private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
// assert(result.isLeft)
// }
// }
// "when Amazon SDK Client Exception" in {
// val exception = new SdkClientException("message")
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3Client.listObjectsV2)
// .when()
// .returns(_ => Task.fail(exception))
// private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
// assert(result.isLeft)
// }
// }
// def invoke(amazonS3Client: AmazonS3Client.Client)(bucket: Bucket,
// prefix: RemoteKey) = {
// object TestEnv extends Storage.Test
// val program: RIO[Storage, RemoteObjects] = Lister
// .listObjects(amazonS3Client)(bucket, prefix)
// val runtime = new DefaultRuntime {}
// runtime.unsafeRunSync(program.provide(TestEnv)).toEither
// }
//
// }
}

View file

@ -1,11 +0,0 @@
package net.kemitix.thorp.storage.aws
import net.kemitix.thorp.domain.MD5Hash
object MD5HashData {
val rootHash = MD5Hash.create("a3a6ac11a0eb577b81b3bb5c95cc8a6e")
val leafHash = MD5Hash.create("208386a650bdec61cfcd7bd8dcb6b542")
}

View file

@ -1,35 +0,0 @@
package net.kemitix.thorp.storage.aws
import scala.jdk.CollectionConverters._
import com.amazonaws.services.s3.model.S3ObjectSummary
import net.kemitix.thorp.domain.{MD5Hash, RemoteKey}
import org.scalatest.FunSpec
class S3ObjectsByHashSuite extends FunSpec {
describe("grouping s3 object together by their hash values") {
val hash = MD5Hash.create("hash")
val key1 = RemoteKey.create("key-1")
val key2 = RemoteKey.create("key-2")
val o1 = s3object(hash, key1)
val o2 = s3object(hash, key2)
val os = List(o1, o2)
it("should group by the hash value") {
val expected: Map[MD5Hash, RemoteKey] = Map(
hash -> key2
)
val result = Map.from(S3Lister.byHash(os.asJava).asScala)
assertResult(expected)(result)
}
}
private def s3object(md5Hash: MD5Hash,
remoteKey: RemoteKey): S3ObjectSummary = {
val summary = new S3ObjectSummary()
summary.setETag(md5Hash.hash())
summary.setKey(remoteKey.key)
summary
}
}

View file

@ -1,109 +0,0 @@
package net.kemitix.thorp.storage.aws
import org.scalamock.scalatest.MockFactory
import org.scalatest.FreeSpec
class UploaderTest extends FreeSpec with MockFactory {
// val uiChannel: UChannel[Any, UIEvent] = zioMessage => ()
//
// "upload" - {
// val aSource: File = Resource(this, "").toFile
// val aFile: File = Resource(this, "small-file").toFile
// val aHash = MD5Hash.create("aHash")
// val hashes = Hashes.create(MD5, aHash)
// val remoteKey = RemoteKey.create("aRemoteKey")
// val localFile =
// LocalFile.create(aFile, aSource, hashes, remoteKey, aFile.length)
// val bucket = Bucket.named("aBucket")
// val uploadResult = new UploadResult
// uploadResult.setKey(remoteKey.key)
// uploadResult.setETag(aHash.hash())
// val listenerSettings =
// UploadEventListener.Settings(uiChannel, localFile, 0, 0, batchMode = true)
// "when no error" in {
// val expected =
// Right(StorageEvent.uploadEvent(remoteKey, aHash))
// val inProgress = new AmazonUpload.InProgress {
// override def waitForUploadResult: Task[UploadResult] =
// Task(uploadResult)
// }
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3TransferManager.uploader)
// .when()
// .returns(_ => UIO.succeed(inProgress))
// private val result =
// invoke(fixture.amazonS3TransferManager)(
// localFile,
// bucket,
// listenerSettings
// )
// assertResult(expected)(result)
// }
// }
// "when Amazon Service Exception" in {
// val exception = new AmazonS3Exception("message")
// val expected =
// Right(
// StorageEvent.errorEvent(ActionSummary.upload(remoteKey.key),
// remoteKey,
// exception))
// val inProgress = new AmazonUpload.InProgress {
// override def waitForUploadResult: Task[UploadResult] =
// Task.fail(exception)
// }
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3TransferManager.upload)
// .when()
// .returns(_ => UIO.succeed(inProgress))
// private val result =
// invoke(fixture.amazonS3TransferManager)(
// localFile,
// bucket,
// listenerSettings
// )
// assertResult(expected)(result)
// }
// }
// "when Amazon SDK Client Exception" in {
// val exception = new SdkClientException("message")
// val expected =
// Right(
// StorageEvent.errorEvent(ActionSummary.upload(remoteKey.key),
// remoteKey,
// exception))
// val inProgress = new AmazonUpload.InProgress {
// override def waitForUploadResult: Task[UploadResult] =
// Task.fail(exception)
// }
// new AmazonS3ClientTestFixture {
// (() => fixture.amazonS3TransferManager.upload)
// .when()
// .returns(_ => UIO.succeed(inProgress))
// private val result =
// invoke(fixture.amazonS3TransferManager)(
// localFile,
// bucket,
// listenerSettings
// )
// assertResult(expected)(result)
// }
// }
// def invoke(transferManager: AmazonTransferManager)(
// localFile: LocalFile,
// bucket: Bucket,
// listenerSettings: UploadEventListener.Settings
// ) = {
// val program = Uploader
// .upload(transferManager)(
// Uploader.Request(localFile, bucket, listenerSettings))
// val runtime = new DefaultRuntime {}
// runtime
// .unsafeRunSync(
// program
// .provide(Config.Live))
// .toEither
// }
// }
}

View file

@ -1,56 +0,0 @@
package net.kemitix.thorp.storage.aws.hasher
import com.amazonaws.services.s3.transfer.TransferManagerConfiguration
import net.kemitix.thorp.filesystem.Resource
import org.scalatest.FreeSpec
class ETagGeneratorTest extends FreeSpec {
private val bigFile = Resource.select(this, "../big-file")
private val bigFilePath = bigFile.toPath
private val configuration = new TransferManagerConfiguration
private val chunkSize = 1200000
configuration.setMinimumUploadPartSize(chunkSize)
// "Create offsets" - {
// "should create offsets" in {
// val offsets = S3ETagGenerator
// .offsets(bigFile.length, chunkSize)
// .foldRight(List[Long]())((l: Long, a: List[Long]) => l :: a)
// assertResult(
// List(0, chunkSize, chunkSize * 2, chunkSize * 3, chunkSize * 4))(
// offsets)
// }
// }
// "create md5 hash for each chunk" - {
// "should create expected hash for chunks" in {
// val md5Hashes = List(
// "68b7d37e6578297621e06f01800204f1",
// "973475b14a7bda6ad8864a7f9913a947",
// "b9adcfc5b103fe2dd5924a5e5e6817f0",
// "5bd6e10a99fef100fe7bf5eaa0a42384",
// "8a0c1d0778ac8fcf4ca2010eba4711eb"
// ).zipWithIndex
// md5Hashes.foreach {
// case (hash, index) =>
// val program = Hasher.hashObjectChunk(bigFilePath, index, chunkSize)
// val result = runtime.unsafeRunSync(program.provide(TestEnv)).toEither
// assertResult(Right(hash))(
// result
// .map(hashes => hashes.get(MD5).get())
// .map(x => x.hash))
// }
// }
// }
// "create etag for whole file" - {
// val expected = "f14327c90ad105244c446c498bfe9a7d-2"
// "should match aws etag for the file" in {
// val program = ETagGenerator.eTag(bigFilePath)
// val result = runtime.unsafeRunSync(program.provide(TestEnv)).toEither
// assertResult(Right(expected))(result)
// }
// }
}