From 9cb8333d0d96c44f884203e80f5c0fdbd0c7db30 Mon Sep 17 00:00:00 2001 From: Wouter Van Ranst Date: Mon, 26 Aug 2024 16:58:01 +0200 Subject: [PATCH] all working unit tests for SqliteStateDbRepositoryFactory --- .../DirectoryInfoExtensions.cs | 3 +- .../Repositories/IStateDbRepositoryFactory.cs | 4 +- src/Arius.Core.Infrastructure/GlobalUsings.cs | 7 + .../InternalsVisibleTo.cs | 8 + .../Repositories/StateDbRepositoryFactory.cs | 124 ++++---- .../DateTimeExtensions.cs | 9 + .../DownloadStateDbCommandHandlerTests.cs | 231 --------------- .../Fixtures/AriusFixture.cs | 5 + .../SqliteStateDbRepositoryFactoryTests.cs | 280 ++++++++++++++++++ .../DownloadStateDb/DownloadStateDbCommand.cs | 119 -------- src/Arius.Core.New/GlobalUsings.cs | 3 +- 11 files changed, 370 insertions(+), 423 deletions(-) create mode 100644 src/Arius.Core.Infrastructure/GlobalUsings.cs create mode 100644 src/Arius.Core.Infrastructure/InternalsVisibleTo.cs create mode 100644 src/Arius.Core.New.UnitTests/DateTimeExtensions.cs delete mode 100644 src/Arius.Core.New.UnitTests/DownloadStateDbCommandHandlerTests.cs create mode 100644 src/Arius.Core.New.UnitTests/SqliteStateDbRepositoryFactoryTests.cs delete mode 100644 src/Arius.Core.New/Commands/DownloadStateDb/DownloadStateDbCommand.cs diff --git a/src/Arius.Core.Domain/DirectoryInfoExtensions.cs b/src/Arius.Core.Domain/DirectoryInfoExtensions.cs index b2ed9312..a5380cfd 100644 --- a/src/Arius.Core.Domain/DirectoryInfoExtensions.cs +++ b/src/Arius.Core.Domain/DirectoryInfoExtensions.cs @@ -2,9 +2,8 @@ public static class DirectoryInfoExtensions { - public static string GetFullFileName(this DirectoryInfo directoryInfo, string fileName) + public static string GetFullName(this DirectoryInfo directoryInfo, string fileName) { return Path.Combine(directoryInfo.FullName, fileName); } - } \ No newline at end of file diff --git a/src/Arius.Core.Domain/Repositories/IStateDbRepositoryFactory.cs b/src/Arius.Core.Domain/Repositories/IStateDbRepositoryFactory.cs index 6e52187a..484d2030 100644 --- a/src/Arius.Core.Domain/Repositories/IStateDbRepositoryFactory.cs +++ b/src/Arius.Core.Domain/Repositories/IStateDbRepositoryFactory.cs @@ -9,5 +9,7 @@ public interface IStateDbRepositoryFactory public interface IStateDbRepository { - + RepositoryVersion Version { get; } + IAsyncEnumerable GetPointerFileEntries(); + IAsyncEnumerable GetBinaryEntries(); } \ No newline at end of file diff --git a/src/Arius.Core.Infrastructure/GlobalUsings.cs b/src/Arius.Core.Infrastructure/GlobalUsings.cs new file mode 100644 index 00000000..982251fb --- /dev/null +++ b/src/Arius.Core.Infrastructure/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using Microsoft.Extensions.Logging; +global using System; +global using System.Linq; +global using System.Threading.Tasks; +global using WouterVanRanst.Utils; +global using WouterVanRanst.Utils.Extensions; +global using System.Threading; \ No newline at end of file diff --git a/src/Arius.Core.Infrastructure/InternalsVisibleTo.cs b/src/Arius.Core.Infrastructure/InternalsVisibleTo.cs new file mode 100644 index 00000000..8635e30c --- /dev/null +++ b/src/Arius.Core.Infrastructure/InternalsVisibleTo.cs @@ -0,0 +1,8 @@ +/* + * This is required to test the internals of the Arius.Core assembly + */ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Arius.Core.New.UnitTests")] +[assembly: InternalsVisibleTo("Arius.ArchUnit")] \ No newline at end of file diff --git a/src/Arius.Core.Infrastructure/Repositories/StateDbRepositoryFactory.cs b/src/Arius.Core.Infrastructure/Repositories/StateDbRepositoryFactory.cs index 106b100c..358a6f57 100644 --- a/src/Arius.Core.Infrastructure/Repositories/StateDbRepositoryFactory.cs +++ b/src/Arius.Core.Infrastructure/Repositories/StateDbRepositoryFactory.cs @@ -3,9 +3,7 @@ using Arius.Core.Domain.Storage; using Azure; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using WouterVanRanst.Utils; namespace Arius.Core.Infrastructure.Repositories; @@ -30,79 +28,60 @@ public async Task CreateAsync(RepositoryOptions repositoryOp { // TODO Validation - var repository = storageAccountFactory.GetRepository(repositoryOptions); + var repository = storageAccountFactory.GetRepository(repositoryOptions); + var localStateDbFolder = config.GetLocalStateDbFolderForRepositoryName(repositoryOptions.ContainerName); - var fullName = await GetLocalRepositoryFullName(); + version ??= await GetLatestVersionAsync(repository) + ?? new RepositoryVersion { Name = $"{DateTime.UtcNow:s}" }; - /* Database is locked -> Cache = shared as per https://docs.microsoft.com/en-us/dotnet/standard/data/sqlite/database-errors - * NOTE if it still fails, try 'pragma temp_store=memory' - */ - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseSqlite($"Data Source={fullName};Cache=Shared", - sqliteOptions => - { - sqliteOptions.CommandTimeout(60); //set command timeout to 60s to avoid concurrency errors on 'table is locked' - }); + var fullName = await GetLocalRepositoryFullNameAsync(repository, localStateDbFolder, version, repositoryOptions.Passphrase); - // Create the repository with the configured DbContext - return new StateDbRepository(optionsBuilder.Options); + var optionsBuilder = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={fullName};Cache=Shared", sqliteOptions => sqliteOptions.CommandTimeout(60)); - async Task GetLocalRepositoryFullName() + return new StateDbRepository(optionsBuilder.Options, version); + } + + private async Task GetLatestVersionAsync(IRepository repository) + { + return await repository + .GetRepositoryVersions() + .OrderBy(b => b.Name) + .LastOrDefaultAsync(); + } + + private async Task GetLocalRepositoryFullNameAsync(IRepository repository, DirectoryInfo stateDbFolder, RepositoryVersion version, string passphrase) + { + var localPath = stateDbFolder.GetFullName(version.GetFileSystemName()); + + if (File.Exists(localPath)) + return localPath; + + try + { + var blob = repository.GetRepositoryVersionBlob(version); + await repository.DownloadAsync(blob, localPath, passphrase); + return localPath; + } + catch (RequestFailedException e) when (e.ErrorCode == "BlobNotFound") + { + throw new ArgumentException("The requested version was not found", nameof(version), e); + } + catch (InvalidDataException e) { - var localStateDbFolder = config.GetLocalStateDbFolderForRepositoryName(repositoryOptions.ContainerName); - - if (version is null) - { - var latestVersion = await GetLatestVersionAsync(); - if (latestVersion == null) - { - // No states yet remotely - this is a fresh archive - return localStateDbFolder.GetFullFileName($"{DateTime.UtcNow:s}"); - } - return await GetLocallyCachedAsync(localStateDbFolder, latestVersion); - } - else - { - return await GetLocallyCachedAsync(localStateDbFolder, version); - } - - async Task GetLatestVersionAsync() - { - return await repository - .GetRepositoryVersions() - .OrderBy(b => b.Name) - .LastOrDefaultAsync(); - } - - async Task GetLocallyCachedAsync(DirectoryInfo stateDbFolder, RepositoryVersion version) - { - var localPath = stateDbFolder.GetFullFileName(version.Name); - - if (File.Exists(localPath)) - { - // Cached locally, ASSUME it’s the same version - return localPath; - } - - try - { - var blob = repository.GetRepositoryVersionBlob(version); - await repository.DownloadAsync(blob, localPath, repositoryOptions.Passphrase); - return localPath; - } - catch (RequestFailedException e) when (e.ErrorCode == "BlobNotFound") - { - throw new ArgumentException("The requested version was not found", nameof(version), e); - } - catch (InvalidDataException e) - { - throw new ArgumentException("Could not load the state database. Probably a wrong passphrase was used.", e); - } - } + throw new ArgumentException("Could not load the state database. Probably a wrong passphrase was used.", e); } } } +public static class RepositoryVersionExtensions +{ + public static string GetFileSystemName(this RepositoryVersion version) + { + return version.Name.Replace(":", ""); + } +} + internal class SqliteStateDbContext : DbContext { public SqliteStateDbContext(DbContextOptions options) @@ -191,14 +170,23 @@ internal class StateDbRepository : IStateDbRepository { private readonly DbContextOptions dbContextOptions; - public StateDbRepository(DbContextOptions dbContextOptions) + public StateDbRepository(DbContextOptions dbContextOptions, RepositoryVersion version) { + Version = version; this.dbContextOptions = dbContextOptions; + + using var context = new SqliteStateDbContext(dbContextOptions); + context.Database.EnsureCreated(); + context.Database.Migrate(); } + public RepositoryVersion Version { get; } + public IAsyncEnumerable GetPointerFileEntries() { - using var context = new SqliteStateDbContext(dbContextOptions); + var context = new SqliteStateDbContext(dbContextOptions); // not with using, maybe detach them all? return context.PointerFileEntries.ToAsyncEnumerable(); } + + public IAsyncEnumerable GetBinaryEntries() => AsyncEnumerable.Empty(); } \ No newline at end of file diff --git a/src/Arius.Core.New.UnitTests/DateTimeExtensions.cs b/src/Arius.Core.New.UnitTests/DateTimeExtensions.cs new file mode 100644 index 00000000..288cf853 --- /dev/null +++ b/src/Arius.Core.New.UnitTests/DateTimeExtensions.cs @@ -0,0 +1,9 @@ +namespace Arius.Core.New.UnitTests; + +internal static class DateTimeExtensions +{ + public static DateTime TruncateToSeconds(this DateTime dateTime) + { + return dateTime.AddTicks(-(dateTime.Ticks % TimeSpan.TicksPerSecond)); + } +} \ No newline at end of file diff --git a/src/Arius.Core.New.UnitTests/DownloadStateDbCommandHandlerTests.cs b/src/Arius.Core.New.UnitTests/DownloadStateDbCommandHandlerTests.cs deleted file mode 100644 index e1fe894f..00000000 --- a/src/Arius.Core.New.UnitTests/DownloadStateDbCommandHandlerTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using Arius.Core.Domain.Storage; -using Arius.Core.Infrastructure.Repositories; -using Arius.Core.New.UnitTests.Fixtures; -using Azure; -using FluentAssertions; -using FluentValidation; -using Microsoft.Extensions.Logging; -using NSubstitute; -using WouterVanRanst.Utils; - -namespace Arius.Core.New.UnitTests; - -public sealed class SqliteStateDbRepositoryFactoryTests : IClassFixture -{ - private readonly RequestHandlerFixture fixture; - - public SqliteStateDbRepositoryFactoryTests(RequestHandlerFixture fixture) - { - this.fixture = fixture; - } - - [Theory] - [InlineData(ServiceConfiguration.Mocked)] - [InlineData(ServiceConfiguration.Real)] - public async Task GetLocalRepositoryFullName_WhenNoVersionsAvailable_ShouldReturnNewFileName(ServiceConfiguration configuration) - { - // Arrange - var factory = fixture.GetStateDbRepositoryFactory(configuration); - var repositoryOptions = fixture.GetRepositoryOptions(configuration); - - if (configuration == ServiceConfiguration.Mocked) - { - var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); - repository.GetRepositoryVersions().Returns(AsyncEnumerable.Empty()); - } - - // Act - var result = await factory.CreateAsync(repositoryOptions); - - // Assert - var expectedFileName = fixture.UnitTestRoot - .GetFullFileName($"{DateTime.UtcNow:s}"); - - result.Should().NotBeNull(); - //result.DbContext.Options.Extensions.Should().ContainSingle(e => - // ((Microsoft.EntityFrameworkCore.Sqlite.Infrastructure.Internal.SqliteOptionsExtension)e) - // .ConnectionString.Contains(expectedFileName)); - } -} - - -//public sealed class DownloadStateDbCommandHandlerTests : IClassFixture -//{ -// private readonly RequestHandlerFixture fixture; - -// public DownloadStateDbCommandHandlerTests(RequestHandlerFixture fixture) -// { -// this.fixture = fixture; -// } - -// [Theory] -// [InlineData(ServiceConfiguration.Mocked)] -// [InlineData(ServiceConfiguration.Real)] -// public async Task Handle_WhenNoVersionsAvailable_ShouldReturnNoStatesYet(ServiceConfiguration configuration) -// { -// // Arrange -// var storageAccountFactory = fixture.GetStorageAccountFactory(configuration); -// var mediator = fixture.GetMediator(configuration); -// var storageAccount = Substitute.For(); - -// var localPath = Path.Combine(fixture.UnitTestRoot.FullName, $"test{Random.Shared.Next()}.db"); -// var command = new DownloadStateDbCommand -// { -// Repository = fixture.GetRepositoryOptions(configuration), -// LocalPath = localPath -// }; - -// if (configuration == ServiceConfiguration.Mocked) -// { -// storageAccountFactory.GetRepository(Arg.Any()).Returns(storageAccount); -// storageAccount.GetRepositoryVersions().Returns(AsyncEnumerable.Empty()); -// } - -// // Act -// var result = await mediator.Send(command); - -// // Assert -// result.Type.Should().Be(DownloadStateDbCommandResultType.NoStatesYet); -// result.Version.Should().BeNull(); -// File.Exists(localPath).Should().BeFalse(); -// } - -// [Theory] -// [InlineData(ServiceConfiguration.Mocked)] -// [InlineData(ServiceConfiguration.Real)] -// public async Task Handle_WhenNoSpecificVersionProvided_ShouldDownloadLatestVersion(ServiceConfiguration configuration) -// { -// // Arrange -// var storageAccountFactory = fixture.GetStorageAccountFactory(configuration); -// var mediator = fixture.GetMediator(configuration); -// var storageAccount = Substitute.For(); - -// var localPath = Path.Combine(fixture.UnitTestRoot.FullName, $"test{Random.Shared.Next()}.db"); -// var command = new DownloadStateDbCommand -// { -// Repository = fixture.GetRepositoryOptions(configuration), -// LocalPath = localPath -// }; - -// if (configuration == ServiceConfiguration.Mocked) -// { -// ConfigureMockRepositoryWithThreeVersions(storageAccountFactory, storageAccount); -// } - -// // Act -// var result = await mediator.Send(command); - -// // Assert -// result.Type.Should().Be(DownloadStateDbCommandResultType.LatestDownloaded); -// result.Version!.Name.Should().Be("v2.0"); -// File.Exists(command.LocalPath).Should().BeTrue(); -// } - -// [Theory] -// [InlineData(ServiceConfiguration.Mocked)] -// [InlineData(ServiceConfiguration.Real)] -// public async Task Handle_WhenSpecificVersionProvided_ShouldDownloadRequestedVersion(ServiceConfiguration configuration) -// { -// // Arrange -// var storageAccountFactory = fixture.GetStorageAccountFactory(configuration); -// var mediator = fixture.GetMediator(configuration); -// var storageAccount = Substitute.For(); - -// var localPath = Path.Combine(fixture.UnitTestRoot.FullName, $"test{Random.Shared.Next()}.db"); -// var command = new DownloadStateDbCommand -// { -// Repository = fixture.GetRepositoryOptions(configuration), -// Version = new RepositoryVersion { Name = "v1.1" }, -// LocalPath = localPath -// }; - -// if (configuration == ServiceConfiguration.Mocked) -// { -// ConfigureMockRepositoryWithThreeVersions(storageAccountFactory, storageAccount); -// } - -// // Act -// var result = await mediator.Send(command); - -// // Assert -// result.Type.Should().Be(DownloadStateDbCommandResultType.RequestedVersionDownloaded); -// result.Version!.Name.Should().Be("v1.1"); -// File.Exists(command.LocalPath).Should().BeTrue(); -// } - -// [Theory] -// [InlineData(ServiceConfiguration.Mocked)] -// [InlineData(ServiceConfiguration.Real)] -// public async Task Handle_WhenSpecificVersionProvidedButDoesNotExist_ShouldReturnException(ServiceConfiguration configuration) -// { -// // Arrange -// var storageAccountFactory = fixture.GetStorageAccountFactory(configuration); -// var mediator = fixture.GetMediator(configuration); -// var storageAccount = Substitute.For(); - -// var localPath = Path.Combine(fixture.UnitTestRoot.FullName, $"test{Random.Shared.Next()}.db"); -// var command = new DownloadStateDbCommand -// { -// Repository = fixture.GetRepositoryOptions(configuration), -// Version = new RepositoryVersion { Name = "v3.0" }, -// LocalPath = localPath -// }; - -// if (configuration == ServiceConfiguration.Mocked) -// { -// ConfigureMockRepositoryWithThreeVersions(storageAccountFactory, storageAccount); - -// var blob = Substitute.For(); -// storageAccount.GetRepositoryVersionBlob(Arg.Any()).Returns(blob); -// blob.OpenReadAsync(Arg.Any()) -// .Returns(Task.FromException(new RequestFailedException(404, "The requested blob was not found", "BlobNotFound", new Exception()))); -// } - -// // Act -// Func act = async () => await mediator.Send(command); - -// // Assert -// await act.Should().ThrowAsync(); -// File.Exists(command.LocalPath).Should().BeFalse(); -// } - - -// private static void ConfigureMockRepositoryWithThreeVersions(IStorageAccountFactory storageAccountFactory, IRepository storageAccount) -// { -// var version1 = new RepositoryVersion { Name = "v1.0" }; -// var version2 = new RepositoryVersion { Name = "v1.1" }; -// var version3 = new RepositoryVersion { Name = "v2.0" }; - -// var blob = Substitute.For(); - -// storageAccountFactory.GetRepository(Arg.Any()).Returns(storageAccount); -// storageAccount.GetRepositoryVersions().Returns(new[] { version1, version2, version3 }.ToAsyncEnumerable()); - -// storageAccount.GetRepositoryVersionBlob(Arg.Any()).Returns(blob); -// } - -// [Fact] -// public async Task Handle_WhenLocalPathAlreadyExists_ShouldFailValidation() -// { -// // Arrange -// var mediator = fixture.GetMediator(ServiceConfiguration.Mocked); - -// var existingFilePath = Path.Combine(Path.GetTempPath(), $"existing-file-{Random.Shared.Next()}.db"); -// File.Create(existingFilePath); // Ensure the file exists - -// var command = new DownloadStateDbCommand -// { -// Repository = fixture.GetRepositoryOptions(ServiceConfiguration.Mocked), // doesnt matter really -// LocalPath = existingFilePath -// }; - -// // Act -// Func act = async () => await mediator.Send(command); - -// // Assert -// await act.Should().ThrowAsync(); - -// //// Cleanup -// //File.Delete(existingFilePath); -// } -//} \ No newline at end of file diff --git a/src/Arius.Core.New.UnitTests/Fixtures/AriusFixture.cs b/src/Arius.Core.New.UnitTests/Fixtures/AriusFixture.cs index ecbd348f..d72d37fe 100644 --- a/src/Arius.Core.New.UnitTests/Fixtures/AriusFixture.cs +++ b/src/Arius.Core.New.UnitTests/Fixtures/AriusFixture.cs @@ -130,6 +130,11 @@ public IStateDbRepositoryFactory GetStateDbRepositoryFactory(ServiceConfiguratio mockedServiceProvider.Value.GetRequiredService() : realServiceProvider.Value.GetRequiredService(); + public AriusConfiguration GetAriusConfiguration(ServiceConfiguration serviceConfiguration) => + serviceConfiguration == ServiceConfiguration.Mocked ? + mockedServiceProvider.Value.GetRequiredService>().Value : + realServiceProvider.Value.GetRequiredService>().Value; + protected abstract void ConfigureServices(IServiceCollection services, ServiceConfiguration serviceConfiguration); public void Dispose() diff --git a/src/Arius.Core.New.UnitTests/SqliteStateDbRepositoryFactoryTests.cs b/src/Arius.Core.New.UnitTests/SqliteStateDbRepositoryFactoryTests.cs new file mode 100644 index 00000000..43dc7803 --- /dev/null +++ b/src/Arius.Core.New.UnitTests/SqliteStateDbRepositoryFactoryTests.cs @@ -0,0 +1,280 @@ +using Arius.Core.Domain.Storage; +using Arius.Core.Infrastructure.Repositories; +using Arius.Core.New.UnitTests.Fixtures; +using Azure; +using FluentAssertions; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using WouterVanRanst.Utils; + +namespace Arius.Core.New.UnitTests; + +public sealed class SqliteStateDbRepositoryFactoryTests : IClassFixture +{ + private readonly RequestHandlerFixture fixture; + + public SqliteStateDbRepositoryFactoryTests(RequestHandlerFixture fixture) + { + this.fixture = fixture; + } + + [Theory] + [InlineData(ServiceConfiguration.Mocked)] + [InlineData(ServiceConfiguration.Real)] + public async Task CreateAsync_WhenNoVersionsAvailable_ShouldReturnNewFileName(ServiceConfiguration configuration) + { + // Arrange + var factory = fixture.GetStateDbRepositoryFactory(configuration); + var repositoryOptions = fixture.GetRepositoryOptions(configuration); + + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + repository.GetRepositoryVersions().Returns(AsyncEnumerable.Empty()); + } + + var startTime = DateTime.UtcNow.TruncateToSeconds(); + + // Act + var result = await factory.CreateAsync(repositoryOptions); + + // Assert + var endTime = DateTime.UtcNow.TruncateToSeconds(); + + DateTime.Parse(result.Version.Name) + .Should() + .BeOnOrAfter(startTime).And.BeOnOrBefore(endTime); // Allow for some slack time in the version name comparison + + File.Exists(GetLocalStateDbForRepositoryFullName(configuration, repositoryOptions, result.Version)) + .Should().BeTrue(); + + (await result.GetPointerFileEntries().CountAsync()).Should().Be(0); + (await result.GetBinaryEntries().CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(ServiceConfiguration.Mocked)] + [InlineData(ServiceConfiguration.Real)] + public async Task CreateAsync_WhenNoVersionSpecifiedAndLatestVersionNotCached_ShouldDownloadLatestVersion(ServiceConfiguration configuration) + { + // Arrange + var factory = fixture.GetStateDbRepositoryFactory(configuration); + var repositoryOptions = fixture.GetRepositoryOptions(configuration); + + if (configuration == ServiceConfiguration.Mocked) + { + var latestVersion = new RepositoryVersion { Name = "v2.0" }; + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + + // Mocking multiple versions + repository.GetRepositoryVersions().Returns(new[] + { + new RepositoryVersion { Name = "v1.0" }, + new RepositoryVersion { Name = "v1.1" }, + latestVersion + }.ToAsyncEnumerable()); + + var blob = Substitute.For(); + repository.GetRepositoryVersionBlob(latestVersion).Returns(blob); + } + + // Act + var result = await factory.CreateAsync(repositoryOptions); + + // Assert + result.Version.Name.Should().Be("v2.0"); + + File.Exists(GetLocalStateDbForRepositoryFullName(configuration, repositoryOptions, result.Version)) + .Should().BeTrue(); + + // Verify DownloadAsync was called + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + await repository.Received(1).DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + } + + [Theory] + [InlineData(ServiceConfiguration.Mocked)] + [InlineData(ServiceConfiguration.Real)] + public async Task CreateAsync_WhenLatestVersionCached_ShouldReturnCachedRepositoryWithCorrectVersion(ServiceConfiguration configuration) + { + // Arrange + var factory = fixture.GetStateDbRepositoryFactory(configuration); + var repositoryOptions = fixture.GetRepositoryOptions(configuration); + string cachedFilePath; + + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + var latestVersion = new RepositoryVersion { Name = "v2.0" }; + cachedFilePath = GetLocalStateDbForRepositoryFullName(configuration, repositoryOptions, latestVersion); + + repository.GetRepositoryVersions().Returns(new[] + { + new RepositoryVersion { Name = "v1.0" }, + new RepositoryVersion { Name = "v1.1" }, + latestVersion + }.ToAsyncEnumerable()); + + // Create the cached file + CreateValidSqliteDatabase(cachedFilePath); + //File.Create(cachedFilePath); + } + else + { + // Real services + cachedFilePath = ""; + throw new NotImplementedException(); + } + + // Act + var result = await factory.CreateAsync(repositoryOptions); + + // Assert + result.Version.Name.Should().Be("v2.0"); + + // Verify DownloadAsync was NOT called since it's cached + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + await repository.DidNotReceive().DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + } + + [Theory] + [InlineData(ServiceConfiguration.Mocked)] + [InlineData(ServiceConfiguration.Real)] + public async Task CreateAsync_WhenSpecificVersionNotCached_ShouldDownloadAndCacheSpecificVersion(ServiceConfiguration configuration) + { + // Arrange + var factory = fixture.GetStateDbRepositoryFactory(configuration); + var repositoryOptions = fixture.GetRepositoryOptions(configuration); + var requestedVersion = new RepositoryVersion { Name = "v1.1" }; + + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + + repository.GetRepositoryVersions().Returns(new[] + { + new RepositoryVersion { Name = "v1.0" }, + requestedVersion, + new RepositoryVersion { Name = "v2.0" } + }.ToAsyncEnumerable()); + + var blob = Substitute.For(); + repository.GetRepositoryVersionBlob(requestedVersion).Returns(blob); + } + + // Act + var result = await factory.CreateAsync(repositoryOptions, requestedVersion); + + // Assert + result.Should().NotBeNull(); + result.Version.Should().NotBeNull(); + result.Version.Name.Should().Be("v1.1"); + + File.Exists(GetLocalStateDbForRepositoryFullName(configuration, repositoryOptions, result.Version)) + .Should().BeTrue(); + + // Verify DownloadAsync was called + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + await repository.Received(1).DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + } + + [Theory] + [InlineData(ServiceConfiguration.Mocked)] + [InlineData(ServiceConfiguration.Real)] + public async Task CreateAsync_WhenSpecificVersionCached_ShouldReturnCachedRepositoryWithCorrectVersion(ServiceConfiguration configuration) + { + // Arrange + var factory = fixture.GetStateDbRepositoryFactory(configuration); + var repositoryOptions = fixture.GetRepositoryOptions(configuration); + var requestedVersion = new RepositoryVersion { Name = "v1.1" }; + var cachedFilePath = GetLocalStateDbForRepositoryFullName(configuration, repositoryOptions, requestedVersion); + + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + + repository.GetRepositoryVersions().Returns(new[] + { + new RepositoryVersion { Name = "v1.0" }, + requestedVersion, + new RepositoryVersion { Name = "v2.0" } + }.ToAsyncEnumerable()); + + // Create the cached file + CreateValidSqliteDatabase(cachedFilePath); + } + + // Act + var result = await factory.CreateAsync(repositoryOptions, requestedVersion); + + // Assert + result.Should().NotBeNull(); + result.Version.Should().NotBeNull(); + result.Version.Name.Should().Be("v1.1"); + + File.Exists(cachedFilePath).Should().BeTrue(); + + // Verify DownloadAsync was NOT called since it's cached + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + await repository.DidNotReceive().DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + } + + [Theory] + [InlineData(ServiceConfiguration.Mocked)] + [InlineData(ServiceConfiguration.Real)] + public async Task CreateAsync_WhenSpecificVersionDoesNotExist_ShouldThrowArgumentException(ServiceConfiguration configuration) + { + // Arrange + var factory = fixture.GetStateDbRepositoryFactory(configuration); + var repositoryOptions = fixture.GetRepositoryOptions(configuration); + var requestedVersion = new RepositoryVersion { Name = "v3.0" }; + + if (configuration == ServiceConfiguration.Mocked) + { + var repository = fixture.GetStorageAccountFactory(configuration).GetRepository(repositoryOptions); + + var blob = Substitute.For(); + repository.GetRepositoryVersionBlob(requestedVersion).Returns(blob); + + repository.DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new RequestFailedException(404, "message", "BlobNotFound", null))); + } + + // Act + Func act = async () => await factory.CreateAsync(repositoryOptions, requestedVersion); + + // Assert + await act.Should().ThrowAsync().WithMessage("The requested version was not found*"); + } + + + private string GetLocalStateDbForRepositoryFullName(ServiceConfiguration configuration, RepositoryOptions repositoryOptions, RepositoryVersion version) + { + return fixture.GetAriusConfiguration(configuration) + .GetLocalStateDbFolderForRepositoryName(repositoryOptions.ContainerName) + .GetFullName(version.GetFileSystemName()); + } + + private void CreateValidSqliteDatabase(string filePath) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite($"Data Source={filePath}"); + + using var context = new SqliteStateDbContext(optionsBuilder.Options); + context.Database.EnsureCreated(); + context.Database.Migrate(); + } +} \ No newline at end of file diff --git a/src/Arius.Core.New/Commands/DownloadStateDb/DownloadStateDbCommand.cs b/src/Arius.Core.New/Commands/DownloadStateDb/DownloadStateDbCommand.cs deleted file mode 100644 index 0c9a330f..00000000 --- a/src/Arius.Core.New/Commands/DownloadStateDb/DownloadStateDbCommand.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Arius.Core.Domain; -using Arius.Core.Domain.Repositories; -using Arius.Core.Domain.Services; -using Arius.Core.Domain.Storage; -using Arius.Core.New.Services; -using Azure; -using FluentValidation; -using MediatR; -using Microsoft.Extensions.Options; - -namespace Arius.Core.New.Commands.DownloadStateDb; - -public record DownloadStateDbCommand : IRequest -{ - public required RepositoryOptions Repository { get; init; } - public RepositoryVersion? Version { get; init; } - public required string LocalPath { get; init; } -} - -internal class DownloadStateDbCommandValidator : AbstractValidator -{ - public DownloadStateDbCommandValidator() - { - RuleFor(command => command.Repository).SetValidator(new RepositoryOptionsValidator()); - RuleFor(command => command.LocalPath) - .NotEmpty() - .Must(localPath => !File.Exists(localPath)).WithMessage("The localpath already exists."); - } -} - -public record DownloadStateDbCommandResult -{ - public required DownloadStateDbCommandResultType Type { get; init; } - public RepositoryVersion? Version { get; init; } -} - -public enum DownloadStateDbCommandResultType -{ - NoStatesYet, - LatestDownloaded, - RequestedVersionDownloaded -} - -internal class DownloadStateDbCommandHandler : IRequestHandler -{ - private readonly IStorageAccountFactory storageAccountFactory; - private readonly ICryptoService cryptoService; - private readonly ILogger logger; - - public DownloadStateDbCommandHandler(IStateDbRepositoryFactory c, IStorageAccountFactory storageAccountFactory, ICryptoService cryptoService, ILogger logger) - { - this.storageAccountFactory = storageAccountFactory; - this.cryptoService = cryptoService; - this.logger = logger; - } - - public async Task Handle(DownloadStateDbCommand request, CancellationToken cancellationToken) - { - await new DownloadStateDbCommandValidator().ValidateAndThrowAsync(request, cancellationToken); - - var repository = storageAccountFactory.GetRepository(request.Repository); - - if (request.Version is null) - { - // Download the latest version - var latestRepositoryVersion = await repository - .GetRepositoryVersions() - .OrderBy(b => b.Name) - .LastOrDefaultAsync(cancellationToken: cancellationToken); - - if (latestRepositoryVersion == null) - return new DownloadStateDbCommandResult - { - Type = DownloadStateDbCommandResultType.NoStatesYet - }; - else - { - var blob = repository.GetRepositoryVersionBlob(latestRepositoryVersion); - - await DownloadAsync(blob, request.LocalPath, request.Repository.Passphrase, cancellationToken); - - return new DownloadStateDbCommandResult - { - Type = DownloadStateDbCommandResultType.LatestDownloaded, - Version = latestRepositoryVersion - }; - } - } - else - { - // Download the requested version - try - { - var blob = repository.GetRepositoryVersionBlob(request.Version); - - await DownloadAsync(blob, request.LocalPath, request.Repository.Passphrase, cancellationToken); - - return new DownloadStateDbCommandResult - { - Type = DownloadStateDbCommandResultType.RequestedVersionDownloaded, - Version = request.Version - }; - } - catch (RequestFailedException e) when (e.ErrorCode == "BlobNotFound") - { - throw new ArgumentException("The requested version was not found", nameof(request.Version), e); - } - } - } - - private async Task DownloadAsync(IBlob blob, string localPath, string passphrase, CancellationToken cancellationToken) - { - await using var ss = await blob.OpenReadAsync(cancellationToken); - await using var ts = File.Create(localPath); - await cryptoService.DecryptAndDecompressAsync(ss, ts, passphrase); - - logger.LogInformation($"Successfully downloaded latest state '{blob.Name}' to '{localPath}'"); - } -} \ No newline at end of file diff --git a/src/Arius.Core.New/GlobalUsings.cs b/src/Arius.Core.New/GlobalUsings.cs index da8afb9f..982251fb 100644 --- a/src/Arius.Core.New/GlobalUsings.cs +++ b/src/Arius.Core.New/GlobalUsings.cs @@ -1,8 +1,7 @@ -// Global using directives - global using Microsoft.Extensions.Logging; global using System; global using System.Linq; global using System.Threading.Tasks; +global using WouterVanRanst.Utils; global using WouterVanRanst.Utils.Extensions; global using System.Threading; \ No newline at end of file