diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index d4bd995..d16cb63 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,59 +5,46 @@ on: branches: [ main ] pull_request: branches: [ main ] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: build-and-test: runs-on: ubuntu-latest - + steps: - - name: checkout - uses: actions/checkout@v3 - - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 7.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore - - - name: Test - run: dotnet test --no-build --logger 'trx;LogFileName=test-results.trx' - - - name: Collect Code Coverage - run: dotnet test --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=ManagedCode.Storage.Tests/lcov.info - - - - name: NDepend - uses: ndepend/ndepend-action@v1 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - license: ${{ secrets.NDEPENDLICENSE }} - coveragefolder: ManagedCode.Storage.Tests - baseline: recent - #baseline: main_recent - - - name : coverlet - uses: b3b00/coverlet-action@1.1.9 - with: - testProject: 'ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj' - output: 'lcov.info' - outputFormat: 'lcov' - excludes: '[program]*,[test]test.*' - - name: coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{secrets.GITHUB_TOKEN }} - path-to-lcov: ManagedCode.Storage.Tests/lcov.info - + - name: checkout + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Test + run: dotnet test --no-build --logger 'trx;LogFileName=test-results.trx' + + - name: Collect Code Coverage + run: dotnet test --no-build /p:CollectCoverage=true /p:CoverletOutput=TestResults/ /p:CoverletOutputFormat=lcov + - name: NDepend + uses: ndepend/ndepend-action@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + license: ${{ secrets.NDEPENDLICENSE }} + coveragefolder: ManagedCode.Storage.Tests/TestResults + baseline: recent + + - name: coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: ManagedCode.Storage.Tests/TestResults/coverage.info \ No newline at end of file diff --git a/ManagedCode.Storage.Azure/BlobStream.cs b/ManagedCode.Storage.Azure/BlobStream.cs index f7d46b9..af68ee2 100644 --- a/ManagedCode.Storage.Azure/BlobStream.cs +++ b/ManagedCode.Storage.Azure/BlobStream.cs @@ -37,18 +37,19 @@ public override long Length { get { - var realLength = 0L; - var metadata = _pageBlob.GetProperties().Value.Metadata; + var properties = _pageBlob.GetProperties(); + var metadata = properties.Value.Metadata; if (metadata.TryGetValue(MetadataLengthKey, out var length)) { - if (long.TryParse(length, out realLength)) + if (long.TryParse(length, out var realLength)) { return realLength; } } - - SetLengthInternal(realLength); - return realLength; + + var contentLenght = properties.Value.ContentLength; + SetLengthInternal(contentLenght); + return contentLenght; } } diff --git a/ManagedCode.Storage.Client/Class1.cs b/ManagedCode.Storage.Client/Class1.cs deleted file mode 100644 index 206aed0..0000000 --- a/ManagedCode.Storage.Client/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ManagedCode.Storage.Client; - -public class Class1 -{ -} \ No newline at end of file diff --git a/ManagedCode.Storage.Client/IStorageClient.cs b/ManagedCode.Storage.Client/IStorageClient.cs new file mode 100644 index 0000000..b0cb19f --- /dev/null +++ b/ManagedCode.Storage.Client/IStorageClient.cs @@ -0,0 +1,17 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Storage.Core.Models; + +namespace ManagedCode.Storage.Client; + +public interface IStorageClient +{ + Task> UploadFile(Stream stream, string apiUrl, string contentName, CancellationToken cancellationToken = default); + Task> UploadFile(FileInfo fileInfo, string apiUrl, string contentName, CancellationToken cancellationToken = default); + Task> UploadFile(byte[] bytes, string apiUrl, string contentName, CancellationToken cancellationToken = default); + Task> UploadFile(string base64, string apiUrl, string contentName, CancellationToken cancellationToken = default); + Task UploadFileInChunks(Stream file, string apiUrl, int chunkSize, CancellationToken cancellationToken = default); + Task> DownloadFile(string fileName, string apiUrl, string? path = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/ManagedCode.Storage.Client/StorageClient.cs b/ManagedCode.Storage.Client/StorageClient.cs new file mode 100644 index 0000000..b6f304a --- /dev/null +++ b/ManagedCode.Storage.Client/StorageClient.cs @@ -0,0 +1,162 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; + +namespace ManagedCode.Storage.Client; + +public class StorageClient : IStorageClient +{ + private readonly HttpClient _httpClient; + + public StorageClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> UploadFile(Stream stream, string apiUrl, string contentName, CancellationToken cancellationToken = default) + { + var streamContent = new StreamContent(stream); + + using (var formData = new MultipartFormDataContent()) + { + formData.Add(streamContent, contentName, contentName); + + var response = await _httpClient.PostAsync(apiUrl, formData, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + return result; + } + + string content = await response.Content.ReadAsStringAsync(cancellationToken: cancellationToken); + + return Result.Fail(response.StatusCode, content); + } + } + + public async Task> UploadFile(FileInfo fileInfo, string apiUrl, string contentName, CancellationToken cancellationToken = default) + { + using var streamContent = new StreamContent(fileInfo.OpenRead()); + + using (var formData = new MultipartFormDataContent()) + { + formData.Add(streamContent, contentName, contentName); + + var response = await _httpClient.PostAsync(apiUrl, formData, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + return result; + } + + return Result.Fail(response.StatusCode); + } + } + + public async Task> UploadFile(byte[] bytes, string apiUrl, string contentName, CancellationToken cancellationToken = default) + { + using (var stream = new MemoryStream()) + { + stream.Write(bytes, 0, bytes.Length); + + using var streamContent = new StreamContent(stream); + + using (var formData = new MultipartFormDataContent()) + { + formData.Add(streamContent, contentName, contentName); + + var response = await _httpClient.PostAsync(apiUrl, formData, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + return result; + } + + return Result.Fail(response.StatusCode); + } + } + } + + public async Task> UploadFile(string base64, string apiUrl, string contentName, CancellationToken cancellationToken = default) + { + byte[] fileAsBytes = Convert.FromBase64String(base64); + using var fileContent = new ByteArrayContent(fileAsBytes); + + using var formData = new MultipartFormDataContent(); + + formData.Add(fileContent, contentName, contentName); + + var response = await _httpClient.PostAsync(apiUrl, formData, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + } + + return Result.Fail(response.StatusCode); + } + + public async Task> DownloadFile(string fileName, string apiUrl, string? path = null, CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetStreamAsync($"{apiUrl}/{fileName}", cancellationToken); + + var localFile = path is null ? await LocalFile.FromStreamAsync(response, fileName) : await LocalFile.FromStreamAsync(response, path, fileName); + + return Result.Succeed(localFile); + } + catch (HttpRequestException e) + { + return Result.Fail(e.StatusCode ?? HttpStatusCode.InternalServerError); + } + } + + public async Task UploadFileInChunks(Stream file, string apiUrl, int chunkSize, CancellationToken cancellationToken) + { + var buffer = new byte[chunkSize]; + int bytesRead; + int chunkIndex = 0; + + while ((bytesRead = await file.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + { + // Create a MemoryStream for the current chunk. + using (var memoryStream = new MemoryStream(buffer, 0, bytesRead)) + { + var content = new StreamContent(memoryStream); + + using (var chunk = new MultipartFormDataContent()) + { + chunk.Add(content, "file", "file"); + + var byteArrayContent = new ByteArrayContent(await chunk.ReadAsByteArrayAsync(cancellationToken)); + // Send the current chunk to the API endpoint. + var response = await _httpClient.PostAsync(apiUrl, byteArrayContent, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return Result.Fail(); + } + } + } + } + + var mergeResult = await _httpClient.PostAsync(apiUrl + "/complete", JsonContent.Create("file")); + + return await mergeResult.Content.ReadFromJsonAsync(); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.Core/Models/LocalFile.cs b/ManagedCode.Storage.Core/Models/LocalFile.cs index 7246768..5e7abba 100644 --- a/ManagedCode.Storage.Core/Models/LocalFile.cs +++ b/ManagedCode.Storage.Core/Models/LocalFile.cs @@ -150,6 +150,17 @@ public static async Task FromStreamAsync(Stream stream) await file.FileStream.DisposeAsync(); return file; } + + public static async Task FromStreamAsync(Stream stream, string path, string fileName) + { + var pathWithName = Path.Combine(path, $"{fileName}.tmp"); + var file = new LocalFile(pathWithName); + + await stream.CopyToAsync(file.FileStream); + await file.FileStream.DisposeAsync(); + + return file; + } public static async Task FromStreamAsync(Stream stream, string fileName) { diff --git a/ManagedCode.Storage.IntegrationTests/Constants/ApiEndpoints.cs b/ManagedCode.Storage.IntegrationTests/Constants/ApiEndpoints.cs new file mode 100644 index 0000000..ff7a73f --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Constants/ApiEndpoints.cs @@ -0,0 +1,13 @@ +namespace ManagedCode.Storage.IntegrationTests.Constants; + +public static class ApiEndpoints +{ + public const string Azure = "azure"; + + public static class Base + { + public const string UploadFile = "{0}/upload"; + public const string UploadFileChunks = "{0}/upload-chunks"; + public const string DownloadFile = "{0}/download"; + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Helpers/Crc32Helper.cs b/ManagedCode.Storage.IntegrationTests/Helpers/Crc32Helper.cs new file mode 100644 index 0000000..b5ef820 --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Helpers/Crc32Helper.cs @@ -0,0 +1,64 @@ +namespace ManagedCode.Storage.IntegrationTests.Helpers; + +public static class Crc32Helper +{ + private static readonly uint[] Crc32Table; + private const uint polynomial = 0xedb88320; + + static Crc32Helper() + { + Crc32Table = new uint[256]; + + for (int i = 0; i < 256; i++) + { + uint crc = (uint)i; + for (int j = 8; j > 0; j--) + { + if ((crc & 1) == 1) + crc = (crc >> 1) ^ polynomial; + else + crc >>= 1; + } + Crc32Table[i] = crc; + } + } + + public static uint Calculate(byte[] bytes) + { + uint crcValue = 0xffffffff; + + foreach (byte by in bytes) + { + byte tableIndex = (byte)(((crcValue) & 0xff) ^ by); + crcValue = Crc32Table[tableIndex] ^ (crcValue >> 8); + } + return ~crcValue; + } + + public static uint CalculateFileCRC(string filePath) + { + uint crcValue = 0xffffffff; + + using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + { + byte[] buffer = new byte[4096]; // 4KB buffer + int bytesRead; + while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0) + { + crcValue = Calculate(buffer, crcValue); + } + } + + return ~crcValue; // Return the final CRC value + } + + public static uint Calculate(byte[] bytes, uint crcValue = 0xffffffff) + { + foreach (byte by in bytes) + { + byte tableIndex = (byte)(((crcValue) & 0xff) ^ by); + crcValue = Crc32Table[tableIndex] ^ (crcValue >> 8); + } + return crcValue; + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Helpers/FileHelper.cs b/ManagedCode.Storage.IntegrationTests/Helpers/FileHelper.cs new file mode 100644 index 0000000..1a6c623 --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Helpers/FileHelper.cs @@ -0,0 +1,71 @@ +using ManagedCode.MimeTypes; +using ManagedCode.Storage.Core.Models; +using Microsoft.AspNetCore.Http; + +namespace ManagedCode.Storage.IntegrationTests.Helpers; + +public static class FileHelper +{ + private static readonly Random Random = new(); + + public static LocalFile GenerateLocalFile(LocalFile file, int sizeInMegabytes) + { + var sizeInBytes = sizeInMegabytes * 1024 * 1024; + + using (var fileStream = file.FileStream) + { + Random random = new Random(); + byte[] buffer = new byte[1024]; // Buffer for writing in chunks + + while (sizeInBytes > 0) + { + int bytesToWrite = (int) Math.Min(sizeInBytes, buffer.Length); + + for (int i = 0; i < bytesToWrite; i++) + { + buffer[i] = (byte) random.Next(65, 91); // 'A' to 'Z' + if (random.Next(2) == 0) + { + buffer[i] = (byte) random.Next(97, 123); // 'a' to 'z' + } + } + + fileStream.Write(buffer, 0, bytesToWrite); + sizeInBytes -= bytesToWrite; + } + } + + return file; + } + + /*public static IFormFile GenerateFormFile(string fileName, int byteSize) + { + var localFile = GenerateLocalFile(fileName, byteSize); + + var ms = new MemoryStream(); + localFile.FileStream.CopyTo(ms); + var formFile = new FormFile(ms, 0, ms.Length, fileName, fileName) + { + Headers = new HeaderDictionary(), + ContentType = MimeHelper.GetMimeType(localFile.FileInfo.Extension) + }; + + localFile.Dispose(); + + return formFile; + } + + public static string GenerateRandomFileName(string extension = "txt") + { + return $"{Guid.NewGuid().ToString("N").ToLowerInvariant()}.{extension}"; + } + + public static string GenerateRandomFileContent() + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789_abcdefghijklmnopqrstuvwxyz"; + + return new string(Enumerable.Repeat(chars, 250_000) + .Select(s => s[Random.Next(s.Length)]) + .ToArray()); + }*/ +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/ManagedCode.Storage.IntegrationTests.csproj b/ManagedCode.Storage.IntegrationTests/ManagedCode.Storage.IntegrationTests.csproj new file mode 100644 index 0000000..b80c8bd --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/ManagedCode.Storage.IntegrationTests.csproj @@ -0,0 +1,39 @@ + + + + net7.0 + enable + enable + false + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/ManagedCode.Storage.IntegrationTests/StorageTestApplication.cs b/ManagedCode.Storage.IntegrationTests/StorageTestApplication.cs new file mode 100644 index 0000000..90609b6 --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/StorageTestApplication.cs @@ -0,0 +1,64 @@ +using Amazon; +using Amazon.S3; +using ManagedCode.Storage.Aws.Extensions; +using ManagedCode.Storage.Azure.Extensions; +using ManagedCode.Storage.Azure.Options; +using ManagedCode.Storage.FileSystem.Extensions; +using ManagedCode.Storage.FileSystem.Options; +using ManagedCode.Storage.Google.Extensions; +using ManagedCode.Storage.Google.Options; +using ManagedCode.Storage.IntegrationTests.TestApp; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Testcontainers.Azurite; +using Xunit; + +namespace ManagedCode.Storage.IntegrationTests; + +[CollectionDefinition(nameof(StorageTestApplication))] +public class StorageTestApplication : WebApplicationFactory, ICollectionFixture +{ + private readonly AzuriteContainer _azuriteContainer; + + public StorageTestApplication() + { + _azuriteContainer = new AzuriteBuilder() + .WithImage("mcr.microsoft.com/azure-storage/azurite:3.26.0") + .Build(); + + _azuriteContainer.StartAsync().Wait(); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureServices(services => + { + #region Add FileSystemStorage + + services.AddFileSystemStorage(new FileSystemStorageOptions + { + BaseFolder = Path.Combine(Environment.CurrentDirectory, "managed-code-bucket") + }); + + #endregion + + #region Add AzureStorage + + services.AddAzureStorage(new AzureStorageOptions + { + Container = "managed-code-bucket", + ConnectionString = _azuriteContainer.GetConnectionString() + }); + + #endregion + }); + + return base.CreateHost(builder); + } + + public override async ValueTask DisposeAsync() + { + await _azuriteContainer.DisposeAsync(); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/TestApp/Controllers/AzureTestController.cs b/ManagedCode.Storage.IntegrationTests/TestApp/Controllers/AzureTestController.cs new file mode 100644 index 0000000..bbd19bb --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/TestApp/Controllers/AzureTestController.cs @@ -0,0 +1,14 @@ +using ManagedCode.Storage.Azure; +using ManagedCode.Storage.IntegrationTests.TestApp.Controllers.Base; +using Microsoft.AspNetCore.Mvc; + +namespace ManagedCode.Storage.IntegrationTests.TestApp.Controllers; + +[Route("azure")] +[ApiController] +public class AzureTestController : BaseTestController +{ + public AzureTestController(IAzureStorage storage) : base(storage) + { + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/TestApp/Controllers/Base/BaseTestController.cs b/ManagedCode.Storage.IntegrationTests/TestApp/Controllers/Base/BaseTestController.cs new file mode 100644 index 0000000..f83eb7a --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/TestApp/Controllers/Base/BaseTestController.cs @@ -0,0 +1,128 @@ +using Amazon.Runtime.Internal; +using ManagedCode.Communication; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ManagedCode.Storage.IntegrationTests.TestApp.Controllers.Base; + +[ApiController] +public abstract class BaseTestController : ControllerBase + where TStorage : IStorage +{ + protected readonly IStorage Storage; + protected readonly ResponseContext ResponseData; + protected readonly int ChunkSize; + + protected BaseTestController(TStorage storage) + { + Storage = storage; + ResponseData = new ResponseContext(); + ChunkSize = 100000000; + } + + [HttpPost("upload")] + public async Task> UploadFileAsync([FromForm] IFormFile file, CancellationToken cancellationToken) + { + if (Request.HasFormContentType is false) + { + return Result.Fail("invalid body"); + } + + return await Storage.UploadAsync(file.OpenReadStream(), cancellationToken); + } + + [HttpGet("download/{fileName}")] + public async Task DownloadFileAsync([FromRoute] string fileName) + { + var result = await Storage.DownloadAsFileResult(fileName); + + result.ThrowIfFail(); + + return result.Value!; + } + + + //create file + //upload chunks + //check file + + [HttpPost("upload-chunks")] + public async Task UploadChunks(CancellationToken cancellationToken) + { + try + { + var chunkNumber = Guid.NewGuid().ToString(); + string newpath = Path.Combine(Path.GetTempPath(), "file" + chunkNumber); + + await using (FileStream fs = System.IO.File.Create(newpath)) + { + byte[] bytes = new byte[ChunkSize]; + int bytesRead = 0; + while ((bytesRead = await Request.Body.ReadAsync(bytes, 0, bytes.Length, cancellationToken)) > 0) + { + await fs.WriteAsync(bytes, 0, bytesRead, cancellationToken); + } + } + } + catch (Exception ex) + { + // _responseData.Response = ex.Message; + // _responseData.IsSuccess = false; + } + + return Ok(ResponseData); + } + + [HttpPost("upload-chunks/complete")] + public async Task UploadComplete([FromBody] string fileName) + { + try + { + string tempPath = Path.GetTempPath(); + string newPath = Path.Combine(tempPath, fileName); + // string[] filePaths = Directory.GetFiles(tempPath).Where(p => p.Contains(fileName)) + // .OrderBy(p => Int32.Parse(p.Replace(fileName, "$").Split('$')[1])).ToArray(); + string[] filePaths = Directory.GetFiles(tempPath).Where(p => p.Contains(fileName)).ToArray(); + foreach (string filePath in filePaths) + { + MergeChunks(newPath, filePath); + } + + System.IO.File.Move(Path.Combine(tempPath, fileName), Path.Combine(tempPath, fileName)); + } + catch (Exception ex) + { + // _responseData.ErrorMessage = ex.Message; + // _responseData.IsSuccess = false; + } + + return Result.Succeed(); + } + + private static void MergeChunks(string chunk1, string chunk2) + { + FileStream fs1 = null; + FileStream fs2 = null; + try + { + fs1 = System.IO.File.Open(chunk1, FileMode.Append); + fs2 = System.IO.File.Open(chunk2, FileMode.Open); + byte[] fs2Content = new byte[fs2.Length]; + fs2.Read(fs2Content, 0, (int)fs2.Length); + fs1.Write(fs2Content, 0, (int)fs2.Length); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message + " : " + ex.StackTrace); + } + finally + { + if (fs1 != null) fs1.Close(); + if (fs2 != null) fs2.Close(); + System.IO.File.Delete(chunk2); + } + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/TestApp/HttpHostProgram.cs b/ManagedCode.Storage.IntegrationTests/TestApp/HttpHostProgram.cs new file mode 100644 index 0000000..cc9a879 --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/TestApp/HttpHostProgram.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.Storage.IntegrationTests.TestApp; + +public class HttpHostProgram +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddControllers(); + builder.Services.AddSignalR(); + builder.Services.AddEndpointsApiExplorer(); + + // By default body size 4 mb + // Full body is 128MB + // builder.Services.Configure(x => { + // x.ValueLengthLimit = int.MaxValue; + // x.MultipartBodyLengthLimit = int.MaxValue; + // x.MultipartHeadersLengthLimit = int.MaxValue; + // }); + + + var app = builder.Build(); + + app.MapControllers(); + + app.Run(); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Tests/Azure/AzureDownloadControllerTests.cs b/ManagedCode.Storage.IntegrationTests/Tests/Azure/AzureDownloadControllerTests.cs new file mode 100644 index 0000000..c160930 --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Tests/Azure/AzureDownloadControllerTests.cs @@ -0,0 +1,10 @@ +using ManagedCode.Storage.IntegrationTests.Constants; + +namespace ManagedCode.Storage.IntegrationTests.Tests.Azure; + +public class AzureDownloadControllerTests : BaseDownloadControllerTests +{ + public AzureDownloadControllerTests(StorageTestApplication testApplication) : base(testApplication, ApiEndpoints.Azure) + { + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Tests/Azure/AzureUploadControllerTests.cs b/ManagedCode.Storage.IntegrationTests/Tests/Azure/AzureUploadControllerTests.cs new file mode 100644 index 0000000..f6f53a5 --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Tests/Azure/AzureUploadControllerTests.cs @@ -0,0 +1,10 @@ +using ManagedCode.Storage.IntegrationTests.Constants; + +namespace ManagedCode.Storage.IntegrationTests.Tests.Azure; + +public class AzureUploadControllerTests : BaseUploadControllerTests +{ + public AzureUploadControllerTests(StorageTestApplication testApplication) : base(testApplication, ApiEndpoints.Azure) + { + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Tests/BaseControllerTests.cs b/ManagedCode.Storage.IntegrationTests/Tests/BaseControllerTests.cs new file mode 100644 index 0000000..660740a --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Tests/BaseControllerTests.cs @@ -0,0 +1,27 @@ +using ManagedCode.Storage.Client; +using Xunit; + +namespace ManagedCode.Storage.IntegrationTests.Tests; + +[Collection(nameof(StorageTestApplication))] +public abstract class BaseControllerTests +{ + protected readonly StorageTestApplication TestApplication; + protected readonly string ApiEndpoint; + + protected BaseControllerTests(StorageTestApplication testApplication, string apiEndpoint) + { + TestApplication = testApplication; + ApiEndpoint = apiEndpoint; + } + + protected HttpClient GetHttpClient() + { + return TestApplication.CreateClient(); + } + + protected IStorageClient GetStorageClient() + { + return new StorageClient(TestApplication.CreateClient()); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Tests/BaseDownloadControllerTests.cs b/ManagedCode.Storage.IntegrationTests/Tests/BaseDownloadControllerTests.cs new file mode 100644 index 0000000..d07860b --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Tests/BaseDownloadControllerTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using FluentAssertions; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.IntegrationTests.Constants; +using ManagedCode.Storage.IntegrationTests.Helpers; +using Xunit; + +namespace ManagedCode.Storage.IntegrationTests.Tests; + +public abstract class BaseDownloadControllerTests : BaseControllerTests +{ + private readonly string _uploadEndpoint; + private readonly string _downloadEndpoint; + + protected BaseDownloadControllerTests(StorageTestApplication testApplication, string apiEndpoint) : base(testApplication, apiEndpoint) + { + _uploadEndpoint = string.Format(ApiEndpoints.Base.UploadFile, ApiEndpoint); + _downloadEndpoint = string.Format(ApiEndpoints.Base.DownloadFile, ApiEndpoint); + } + + [Fact] + public async Task DownloadFile_WhenFileExists_SaveToTempStorage_ReturnSuccess() + { + // Arrange + var storageClient = GetStorageClient(); + var contentName = "file"; + + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 1); + var fileCRC = Crc32Helper.Calculate(await localFile.ReadAllBytesAsync()); + var uploadFileBlob = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName); + + // Act + var downloadedFileResult = await storageClient.DownloadFile(uploadFileBlob.Value.FullName, _downloadEndpoint); + + // Assert + downloadedFileResult.IsSuccess.Should().BeTrue(); + downloadedFileResult.Value.Should().NotBeNull(); + var downloadedFileCRC = Crc32Helper.CalculateFileCRC(downloadedFileResult.Value.FilePath); + downloadedFileCRC.Should().Be(fileCRC); + } + + [Fact] + public async Task DownloadFile_WhenFileDoNotExist_ReturnFail() + { + // Arrange + var storageClient = GetStorageClient();; + + // Act + var downloadedFileResult = await storageClient.DownloadFile(Guid.NewGuid().ToString(), _downloadEndpoint); + + // Assert + downloadedFileResult.IsFailed.Should().BeTrue(); + downloadedFileResult.GetError().Value.ErrorCode.Should().Be(HttpStatusCode.InternalServerError.ToString()); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.IntegrationTests/Tests/BaseUploadControllerTests.cs b/ManagedCode.Storage.IntegrationTests/Tests/BaseUploadControllerTests.cs new file mode 100644 index 0000000..c6ed9bd --- /dev/null +++ b/ManagedCode.Storage.IntegrationTests/Tests/BaseUploadControllerTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using FluentAssertions; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.IntegrationTests.Constants; +using ManagedCode.Storage.IntegrationTests.Helpers; +using Xunit; + +namespace ManagedCode.Storage.IntegrationTests.Tests; + +public abstract class BaseUploadControllerTests : BaseControllerTests +{ + private readonly string _uploadEndpoint; + private readonly string _uploadChunksEndpoint; + + protected BaseUploadControllerTests(StorageTestApplication testApplication, string apiEndpoint) : base(testApplication, apiEndpoint) + { + _uploadEndpoint = string.Format(ApiEndpoints.Base.UploadFile, ApiEndpoint); + _uploadChunksEndpoint = string.Format(ApiEndpoints.Base.UploadFileChunks, ApiEndpoint); + } + + [Fact] + public async Task UploadFileFromStream_WhenFileValid_ReturnSuccess() + { + // Arrange + var storageClient = GetStorageClient(); + var contentName = "file"; + + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 1); + + // Act + var result = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task UploadFileFromStream_WhenFileSizeIsForbidden_ReturnFail() + { + // Arrange + var storageClient = GetStorageClient(); + var contentName = "file"; + + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 200); + + // Act + var result = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName); + + // Assert + result.IsFailed.Should().BeTrue(); + result.GetError().Value.ErrorCode.Should().Be(HttpStatusCode.BadRequest.ToString()); + } + + [Fact] + public async Task UploadFileFromFileInfo_WhenFileValid_ReturnSuccess() + { + // Arrange + var storageClient = GetStorageClient(); + var fileName = "test.txt"; + var contentName = "file"; + + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 1); + + // Act + var result = await storageClient.UploadFile(localFile.FileInfo, _uploadEndpoint, contentName); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task UploadFileFromBytes_WhenFileValid_ReturnSuccess() + { + // Arrange + var storageClient = GetStorageClient(); + var fileName = "test.txt"; + var contentName = "file"; + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 1); + + var fileAsBytes = await localFile.ReadAllBytesAsync(); + + // Act + var result = await storageClient.UploadFile(fileAsBytes, _uploadEndpoint, contentName); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task UploadFileFromBase64String_WhenFileValid_ReturnSuccess() + { + // Arrange + var storageClient = GetStorageClient(); + var fileName = "test.txt"; + var contentName = "file"; + + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 1); + + var fileAsBytes = await localFile.ReadAllBytesAsync(); + var fileAsString64 = Convert.ToBase64String(fileAsBytes); + + // Act + var result = await storageClient.UploadFile(fileAsString64, _uploadEndpoint, contentName); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task UploadFileInChunks_WhenFileValid_ReturnSuccess() + { + // Arrange + var storageClient = GetStorageClient(); + var fileName = "test.txt"; + var contentName = "file"; + + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFile(localFile, 20); + + // Act + var result = await storageClient.UploadFileInChunks(localFile.FileStream, _uploadChunksEndpoint, 100000000); + + // Assert + result.IsSuccess.Should().BeTrue(); + //result.Value.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.Server/BaseController.cs b/ManagedCode.Storage.Server/BaseController.cs index 79a11dd..624e0f6 100644 --- a/ManagedCode.Storage.Server/BaseController.cs +++ b/ManagedCode.Storage.Server/BaseController.cs @@ -12,48 +12,48 @@ namespace ManagedCode.Storage.Server; -public class BaseController : ControllerBase +public abstract class BaseController : ControllerBase { - private readonly IStorage _storage; + protected readonly IStorage Storage; - public BaseController(IStorage storage) + protected BaseController(IStorage storage) { - _storage = storage; + Storage = storage; } protected async Task>UploadToStorageAsync(IBrowserFile formFile, UploadOptions? options = null) { - return await _storage.UploadToStorageAsync(formFile, options); + return await Storage.UploadToStorageAsync(formFile, options); } protected async Task>UploadToStorageAsync(IBrowserFile formFile, Action options) { - return await _storage.UploadToStorageAsync(formFile, options); + return await Storage.UploadToStorageAsync(formFile, options); } protected async Task> DownloadAsFileResult(string blobName, CancellationToken cancellationToken = default) { - return await _storage.DownloadAsFileResult(blobName, cancellationToken); + return await Storage.DownloadAsFileResult(blobName, cancellationToken); } protected async Task> DownloadAsFileResult(BlobMetadata blobMetadata, CancellationToken cancellationToken = default) { - return await _storage.DownloadAsFileResult(blobMetadata, cancellationToken); + return await Storage.DownloadAsFileResult(blobMetadata, cancellationToken); } protected async Task> UploadToStorageAsync(IFormFile formFile, UploadOptions? options = null, CancellationToken cancellationToken = default) { - return await _storage.UploadToStorageAsync(formFile, options, cancellationToken); + return await Storage.UploadToStorageAsync(formFile, options, cancellationToken); } protected async Task> UploadToStorageAsync(IFormFile formFile, Action options, CancellationToken cancellationToken = default) { - return await _storage.UploadToStorageAsync(formFile, options, cancellationToken); + return await Storage.UploadToStorageAsync(formFile, options, cancellationToken); } protected async IAsyncEnumerable> UploadToStorageAsync(IFormFileCollection formFiles, UploadOptions? options = null, @@ -61,7 +61,7 @@ protected async Task> DownloadAsFileResult(BlobMetadata blobM { foreach (var formFile in formFiles) { - yield return await _storage.UploadToStorageAsync(formFile, options, cancellationToken); + yield return await Storage.UploadToStorageAsync(formFile, options, cancellationToken); } } protected async IAsyncEnumerable> UploadToStorageAsync(IFormFileCollection formFiles, @@ -70,7 +70,7 @@ protected async Task> DownloadAsFileResult(BlobMetadata blobM { foreach (var formFile in formFiles) { - yield return await _storage.UploadToStorageAsync(formFile, options, cancellationToken); + yield return await Storage.UploadToStorageAsync(formFile, options, cancellationToken); } } diff --git a/ManagedCode.Storage.Tests/Common/FileHelper.cs b/ManagedCode.Storage.Tests/Common/FileHelper.cs index 4d5c8c7..34df22b 100644 --- a/ManagedCode.Storage.Tests/Common/FileHelper.cs +++ b/ManagedCode.Storage.Tests/Common/FileHelper.cs @@ -25,6 +25,34 @@ public static LocalFile GenerateLocalFile(string fileName, int byteSize) return localFile; } + public static LocalFile GenerateLocalFileWithData(LocalFile file, int sizeInBytes) + { + using (var fileStream = file.FileStream) + { + Random random = new Random(); + byte[] buffer = new byte[1024]; // Buffer for writing in chunks + + while (sizeInBytes > 0) + { + int bytesToWrite = (int) Math.Min(sizeInBytes, buffer.Length); + + for (int i = 0; i < bytesToWrite; i++) + { + buffer[i] = (byte) random.Next(65, 91); // 'A' to 'Z' + if (random.Next(2) == 0) + { + buffer[i] = (byte) random.Next(97, 123); // 'a' to 'z' + } + } + + fileStream.Write(buffer, 0, bytesToWrite); + sizeInBytes -= bytesToWrite; + } + } + + return file; + } + public static IFormFile GenerateFormFile(string fileName, int byteSize) { var localFile = GenerateLocalFile(fileName, byteSize); diff --git a/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs b/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs new file mode 100644 index 0000000..9861408 --- /dev/null +++ b/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using ManagedCode.Storage.Azure; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Tests.Common; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.Azurite; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Azure; + +public class AzureBlobStreamTests : StreamTests +{ + protected override AzuriteContainer Build() + { + return new AzuriteBuilder() + .WithImage("mcr.microsoft.com/azure-storage/azurite:3.26.0") + .Build(); + } + + protected override ServiceProvider ConfigureServices() + { + return AzureConfigurator.ConfigureServices(Container.GetConnectionString()); + } + + [Fact] + public async Task ReadStreamWithStreamReader_WhenFileExists_ReturnData() + { + // Arrange + var directory = "test-directory"; + var fileSizeInBytes = 10; + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFileWithData(localFile, fileSizeInBytes); + var storage = (IAzureStorage) Storage; + + UploadOptions options = new() { FileName = localFile.Name, Directory = directory }; + await using var localFileStream = localFile.FileInfo.OpenRead(); + var result = await storage.UploadAsync(localFileStream, options); + + await using var blobStream = storage.GetBlobStream(result.Value.FullName); + + // Act + using var streamReader = new StreamReader(blobStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + await using var fileStream = localFile.FileInfo.OpenRead(); + using var fileReader = new StreamReader(fileStream); + var fileContent = await fileReader.ReadToEndAsync(); + content.Should().NotBeNullOrEmpty(); + fileContent.Should().NotBeNullOrEmpty(); + content.Should().Be(fileContent); + } + + [Fact] + public async Task ReadStream_WhenFileExists_ReturnData() + { + // Arrange + var directory = "test-directory"; + var fileSizeInBytes = 10; + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFileWithData(localFile, fileSizeInBytes); + var storage = (IAzureStorage) Storage; + + UploadOptions options = new() { FileName = localFile.Name, Directory = directory }; + await using var fileStream = localFile.FileInfo.OpenRead(); + var result = await storage.UploadAsync(fileStream, options); + + await using var blobStream = storage.GetBlobStream(result.Value.FullName); + + var chunkSize = (int) blobStream.Length / 2; + var chunk1 = new byte[chunkSize]; + var chunk2 = new byte[chunkSize]; + + // Act + var bytesReadForChunk1 = await blobStream.ReadAsync(chunk1, 0, chunkSize); + var bytesReadForChunk2 = await blobStream.ReadAsync(chunk2, 0, chunkSize); + + // Assert + bytesReadForChunk1.Should().Be(chunkSize); + bytesReadForChunk2.Should().Be(chunkSize); + chunk1.Should().NotBeNullOrEmpty().And.HaveCount(chunkSize); + chunk2.Should().NotBeNullOrEmpty().And.HaveCount(chunkSize); + } + + [Fact] + public async Task ReadStream_WhenFileDoesNotExists_ReturnNoData() + { + // Arrange + var directory = "test-directory"; + var storage = (IAzureStorage) Storage; + await storage.CreateContainerAsync(); + var fullFileName = $"{directory}/{Guid.NewGuid()}.txt"; + + await using var blobStream = storage.GetBlobStream(fullFileName); + var chunk = new byte[4]; + + // Act + var bytesRead = await blobStream.ReadAsync(chunk, 0, 4); + + // Assert + bytesRead.Should().Be(0); + chunk.Should().NotBeNullOrEmpty(); + chunk.Should().AllBeEquivalentTo(0); + } + + [Fact] + public async Task WriteStreamWithStreamWriter_SaveData() + { + // Arrange + var directory = "test-directory"; + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + var fileSizeInBytes = 10; + FileHelper.GenerateLocalFileWithData(localFile, fileSizeInBytes); + var fullFileName = $"{directory}/{localFile.FileInfo.FullName}"; + + var storage = (IAzureStorage) Storage; + + await storage.CreateContainerAsync(); + + // Act + await using (var blobStream = storage.GetBlobStream(fullFileName)) + { + await using (var localFileStream = localFile.FileStream) + { + await localFileStream.CopyToAsync(blobStream); + } + } + + // Assert + var fileResult = await storage.DownloadAsync(fullFileName); + fileResult.IsSuccess.Should().BeTrue(); + fileResult.Value.Should().NotBeNull(); + await using var fileStream = fileResult.Value.FileStream; + using var streamReader = new StreamReader(fileStream); + var fileContent = await streamReader.ReadLineAsync(); + fileContent.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Seek_WhenFileExists_ReturnData() + { + // Arrange + var directory = "test-directory"; + var fileSizeInBytes = 10; + await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); + FileHelper.GenerateLocalFileWithData(localFile, fileSizeInBytes); + var storage = (IAzureStorage) Storage; + + UploadOptions options = new() { FileName = localFile.Name, Directory = directory }; + await using var localFileStream = localFile.FileInfo.OpenRead(); + var result = await storage.UploadAsync(localFileStream, options); + + await using var blobStream = storage.GetBlobStream(result.Value.FullName); + + // Act + var seekInPosition = fileSizeInBytes / 2; + blobStream.Seek(seekInPosition, SeekOrigin.Current); + var buffer = new byte[seekInPosition]; + var bytesRead = await blobStream.ReadAsync(buffer); + + // Assert + bytesRead.Should().Be(seekInPosition); + await using var fileStream = localFile.FileInfo.OpenRead(); + using var fileReader = new StreamReader(fileStream); + var fileContent = await fileReader.ReadToEndAsync(); + var content = Encoding.UTF8.GetString(buffer); + content.Should().NotBeNullOrEmpty(); + var trimmedFileContent = fileContent.Remove(0, seekInPosition); + content.Should().Be(trimmedFileContent); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs b/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs index ad8a87d..3cd9418 100644 --- a/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs +++ b/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs @@ -6,7 +6,7 @@ namespace ManagedCode.Storage.Tests.Storages.GCS; -[Collection("Google")] + public class GCSBlobTests : BlobTests { protected override GCSContainer Build() diff --git a/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigTests.cs b/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigTests.cs index 2a0f464..520d1a0 100644 --- a/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigTests.cs +++ b/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigTests.cs @@ -11,7 +11,7 @@ namespace ManagedCode.Storage.Tests.Storages.GCS; -[Collection("Google")] + public class GCSConfigTests { [Fact] diff --git a/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigurator.cs b/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigurator.cs index 531a161..c9338cf 100644 --- a/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigurator.cs +++ b/ManagedCode.Storage.Tests/Storages/GCS/GCSConfigurator.cs @@ -6,7 +6,7 @@ namespace ManagedCode.Storage.Tests.Storages.GCS; -[Collection("Google")] + public class GCSConfigurator { public static ServiceProvider ConfigureServices(string connectionString) diff --git a/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs b/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs index 4163fa8..1536c9a 100644 --- a/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs +++ b/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs @@ -4,7 +4,7 @@ namespace ManagedCode.Storage.Tests.Storages.GCS; -[Collection("Google")] + public class GCSContainerTests : ContainerTests { protected override GCSContainer Build() diff --git a/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs b/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs index 2b14cce..3424cce 100644 --- a/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs +++ b/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs @@ -4,7 +4,7 @@ namespace ManagedCode.Storage.Tests.Storages.GCS; -[Collection("Google")] + public class GCSDownloadTests : DownloadTests { protected override GCSContainer Build() diff --git a/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs b/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs index 24c3b45..2a61349 100644 --- a/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs +++ b/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs @@ -4,7 +4,7 @@ namespace ManagedCode.Storage.Tests.Storages.GCS; -[Collection("Google")] + public class GCSUploadTests : UploadTests { protected override GCSContainer Build() diff --git a/ManagedCode.Storage.Tests/Storages/StreamTests.cs b/ManagedCode.Storage.Tests/Storages/StreamTests.cs new file mode 100644 index 0000000..ae4f8de --- /dev/null +++ b/ManagedCode.Storage.Tests/Storages/StreamTests.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using DotNet.Testcontainers.Containers; +using ManagedCode.Storage.Tests.Common; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages; + +public abstract class StreamTests : BaseContainer + where T : IContainer +{ + +} \ No newline at end of file diff --git a/ManagedCode.Storage.sln b/ManagedCode.Storage.sln index 0e21653..0c4e308 100644 --- a/ManagedCode.Storage.sln +++ b/ManagedCode.Storage.sln @@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Storage.Client" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Storage.Client.SignalR", "ManagedCode.Storage.Client.SignalR\ManagedCode.Storage.Client.SignalR.csproj", "{ED216AAD-CBA2-40F2-AA01-63C60E906632}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E609A83E-6400-42B0-AD5A-5B006EABC275}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Storage.IntegrationTests", "ManagedCode.Storage.IntegrationTests\ManagedCode.Storage.IntegrationTests.csproj", "{39EFEB67-4C0F-4FAD-8FE8-06D7A5D02FEE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +97,10 @@ Global {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|Any CPU.Build.0 = Debug|Any CPU {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|Any CPU.ActiveCfg = Release|Any CPU {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|Any CPU.Build.0 = Release|Any CPU + {39EFEB67-4C0F-4FAD-8FE8-06D7A5D02FEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39EFEB67-4C0F-4FAD-8FE8-06D7A5D02FEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39EFEB67-4C0F-4FAD-8FE8-06D7A5D02FEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39EFEB67-4C0F-4FAD-8FE8-06D7A5D02FEE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -110,5 +118,8 @@ Global {852B0DBD-37F0-4DC0-B966-C284AE03C2F5} = {94DB7354-F5C7-4347-B9EC-FCCA38B86876} {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C} = {94DB7354-F5C7-4347-B9EC-FCCA38B86876} {ED216AAD-CBA2-40F2-AA01-63C60E906632} = {94DB7354-F5C7-4347-B9EC-FCCA38B86876} + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F} = {E609A83E-6400-42B0-AD5A-5B006EABC275} + {40638DBB-CB6F-4B11-B4B5-50446CE426E7} = {E609A83E-6400-42B0-AD5A-5B006EABC275} + {39EFEB67-4C0F-4FAD-8FE8-06D7A5D02FEE} = {E609A83E-6400-42B0-AD5A-5B006EABC275} EndGlobalSection EndGlobal diff --git a/TestcontainersGCS/GCSBuilder.cs b/TestcontainersGCS/GCSBuilder.cs index eef6bf5..9e76267 100644 --- a/TestcontainersGCS/GCSBuilder.cs +++ b/TestcontainersGCS/GCSBuilder.cs @@ -1,11 +1,15 @@ +using System.Text; +using System.Text.RegularExpressions; + namespace TestcontainersGCS; /// [PublicAPI] public sealed class GCSBuilder : ContainerBuilder { - public const string GCSImage = "fsouza/fake-gcs-server:1.47.5"; - public const ushort GCSPort = 30000; + public const string FakeGCSServerImage = "fsouza/fake-gcs-server:1.47.5"; + public const ushort FakeGCSServerPort = 4443; + public const string StartupScriptFilePath = "/testcontainers.sh"; /// /// Initializes a new instance of the class. @@ -40,14 +44,21 @@ public override GCSContainer Build() protected override GCSBuilder Init() { return base.Init() - .WithImage(GCSImage) - .WithPortBinding(GCSPort, GCSPort) - .WithCommand("-scheme", "http") - .WithCommand("-backend", "memory") - .WithCommand("-external-url", $"http://localhost:{GCSPort}") - .WithCommand("-port", $"{GCSPort}") - .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => - request.ForPath("/").ForPort(GCSPort).ForStatusCode(HttpStatusCode.NotFound))); + .WithImage(FakeGCSServerImage) + .WithPortBinding(FakeGCSServerPort, true) + .WithEntrypoint("/bin/sh", "-c") + .WithCommand($"while [ ! -f {StartupScriptFilePath} ]; do sleep 0.1; done; sh {StartupScriptFilePath}") + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged(new Regex("server started at.*", RegexOptions.IgnoreCase))) + .WithStartupCallback((container, ct) => + { + const char lf = '\n'; + var startupScript = new StringBuilder(); + startupScript.Append("#!/bin/bash"); + startupScript.Append(lf); + startupScript.Append($"fake-gcs-server -backend memory -scheme http -port {FakeGCSServerPort} -external-url \"http://localhost:{container.GetMappedPublicPort(FakeGCSServerPort)}\""); + startupScript.Append(lf); + return container.CopyAsync(Encoding.Default.GetBytes(startupScript.ToString()), StartupScriptFilePath, Unix.FileMode755, ct); + }); } /// diff --git a/TestcontainersGCS/GCSContainer.cs b/TestcontainersGCS/GCSContainer.cs index 2a7cf48..db5d2f6 100644 --- a/TestcontainersGCS/GCSContainer.cs +++ b/TestcontainersGCS/GCSContainer.cs @@ -20,7 +20,7 @@ public GCSContainer(GCSConfiguration configuration, ILogger logger) /// The GCS connection string. public string GetConnectionString() { - var builder = new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(GCSBuilder.GCSPort), "storage/v1/"); + var builder = new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(GCSBuilder.FakeGCSServerPort), "storage/v1/"); return builder.ToString(); } } \ No newline at end of file