diff --git a/Directory.Packages.props b/Directory.Packages.props index a2937ba..e316022 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,7 @@ + diff --git a/infra/main.bicep b/infra/main.bicep index a5ce550..6f5979c 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -49,6 +49,7 @@ module resources './resources.bicep' = { /*********************************************************************************/ output AZSQL_CONNECTION_STRING string = resources.outputs.AZSQL_CONNECTIONSTRING -output PGSQL_CONNECTION_STRING string = resources.outputs.PGSQL_CONNECTIONSTRING output COSMOS_CONNECTION_STRING string = resources.outputs.COSMOS_CONNECTIONSTRING +output MYSQL_CONNECTION_STRING string = resources.outputs.MYSQL_CONNECTIONSTRING +output PGSQL_CONNECTION_STRING string = resources.outputs.PGSQL_CONNECTIONSTRING output SERVICE_ENDPOINT string = resources.outputs.SERVICE_ENDPOINT diff --git a/infra/modules/mysql.bicep b/infra/modules/mysql.bicep new file mode 100644 index 0000000..8d0397e --- /dev/null +++ b/infra/modules/mysql.bicep @@ -0,0 +1,89 @@ +targetScope = 'resourceGroup' + +@description('The list of firewall rules to install') +param firewallRules FirewallRule[] = [ + { startIpAddress: '0.0.0.0', endIpAddress: '0.0.0.0' } +] + +@minLength(1) +@description('The name of the test database to create') +param databaseName string = 'unittests' + +@minLength(1) +@description('Primary location for all resources') +param location string = resourceGroup().location + +@description('The name of the SQL Server to create.') +param sqlServerName string + +@description('Optional - the SQL Server administrator password. If not provided, the username will be \'appadmin\'.') +param sqlAdminUsername string = 'appadmin' + +@secure() +@description('Optional - SQL Server administrator password. If not provided, a random password will be generated.') +param sqlAdminPassword string = newGuid() + +@description('The list of tags to apply to all resources.') +param tags object = {} + +/*********************************************************************************/ + +resource mysql_server 'Microsoft.DBforMySQL/flexibleServers@2024-10-01-preview' = { + name: sqlServerName + location: location + tags: tags + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + administratorLogin: sqlAdminUsername + administratorLoginPassword: sqlAdminPassword + createMode: 'Default' + authConfig: { + activeDirectoryAuth: 'Disabled' + passwordAuth: 'Enabled' + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + storage: { + storageSizeGB: 32 + autoGrow: 'Disabled' + } + version: '8.0.21' + } + + resource fw 'firewallRules@2023-12-30' = [ for (fwRule, idx) in firewallRules : { + name: 'fw${idx}' + properties: { + startIpAddress: fwRule.startIpAddress + endIpAddress: fwRule.endIpAddress + } + }] +} + +resource mysql_database 'Microsoft.DBforMySQL/flexibleServers/databases@2023-12-30' = { + name: databaseName + parent: mysql_server + properties: { + charset: 'ascii' + collation: 'ascii_general_ci' + } +} + +/*********************************************************************************/ + +#disable-next-line outputs-should-not-contain-secrets +output MYSQL_CONNECTIONSTRING string = 'server=${mysql_server.properties.fullyQualifiedDomainName};database=${mysql_database.name};user=${mysql_server.properties.administratorLogin};password=${sqlAdminPassword}' + +/*********************************************************************************/ + +type FirewallRule = { + startIpAddress: string + endIpAddress: string +} diff --git a/infra/resources.bicep b/infra/resources.bicep index ce72f1d..f42f39e 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -34,6 +34,7 @@ var appServiceName = 'web-${resourceToken}' var azsqlServerName = 'sql-${resourceToken}' var cosmosServerName = 'cosmos-${resourceToken}' var pgsqlServerName = 'pgsql-${resourceToken}' +var mysqlServerName = 'mysql-${resourceToken}' var testDatabaseName = 'unittests' var cosmosContainerName = 'Movies' @@ -78,6 +79,19 @@ module pgsql './modules/postgresql.bicep' = { } } +module mysql './modules/mysql.bicep' = { + name: 'mysql-deployment-${resourceToken}' + params: { + location: location + tags: tags + databaseName: testDatabaseName + firewallRules: clientIpFirewallRules + sqlServerName: mysqlServerName + sqlAdminUsername: sqlAdminUsername + sqlAdminPassword: sqlAdminPassword + } +} + module cosmos './modules/cosmos.bicep' = { name: 'cosmos-deployment-${resourceToken}' params: { @@ -109,6 +123,7 @@ module app_service './modules/appservice.bicep' = { /*********************************************************************************/ output AZSQL_CONNECTIONSTRING string = azuresql.outputs.AZSQL_CONNECTIONSTRING -output PGSQL_CONNECTIONSTRING string = pgsql.outputs.PGSQL_CONNECTIONSTRING output COSMOS_CONNECTIONSTRING string = cosmos.outputs.COSMOS_CONNECTIONSTRING +output MYSQL_CONNECTIONSTRING string = mysql.outputs.MYSQL_CONNECTIONSTRING +output PGSQL_CONNECTIONSTRING string = pgsql.outputs.PGSQL_CONNECTIONSTRING output SERVICE_ENDPOINT string = app_service.outputs.SERVICE_ENDPOINT diff --git a/infra/scripts/write-runsettings.ps1 b/infra/scripts/write-runsettings.ps1 index fc07d32..cd288ae 100644 --- a/infra/scripts/write-runsettings.ps1 +++ b/infra/scripts/write-runsettings.ps1 @@ -13,6 +13,7 @@ $fileContents = @" $($outputs.AZSQL_CONNECTION_STRING) $($outputs.COSMOS_CONNECTION_STRING) + $($outputs.MYSQL_CONNECTION_STRING) $($outputs.PGSQL_CONNECTION_STRING) $($outputs.SERVICE_ENDPOINT) true diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs index 70c2534..e8412b0 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableData.cs @@ -21,5 +21,5 @@ public class EntityTableData : BaseEntityTableData /// [Timestamp] - public override byte[] Version { get; set; } = Array.Empty(); + public override byte[] Version { get; set; } = []; } diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs new file mode 100644 index 0000000..42d639d --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.TestCommon; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; + +[ExcludeFromCodeCoverage] +[Collection("LiveTestsCollection")] +public class MysqlEntityTableRepository_Tests : RepositoryTests +{ + #region Setup + private readonly DatabaseFixture _fixture; + private readonly Random random = new(); + private readonly string connectionString; + private readonly List movies; + private readonly Lazy _context; + + public MysqlEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base() + { + this._fixture = fixture; + this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_MYSQL_CONNECTIONSTRING"); + if (!string.IsNullOrEmpty(this.connectionString)) + { + this._context = new Lazy(() => MysqlDbContext.CreateContext(this.connectionString, output)); + this.movies = Context.Movies.AsNoTracking().ToList(); + } + } + + private MysqlDbContext Context { get => this._context.Value; } + + protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); + + protected override Task GetEntityAsync(string id) + => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id)); + + protected override Task GetEntityCountAsync() + => Task.FromResult(Context.Movies.Count()); + + protected override Task> GetPopulatedRepositoryAsync() + => Task.FromResult>(new EntityTableRepository(Context)); + + protected override Task GetRandomEntityIdAsync(bool exists) + => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); + #endregion + + [SkippableFact] + public void EntityTableRepository_BadDbSet_Throws() + { + Skip.IfNot(CanRunLiveTests()); + Action act = () => _ = new EntityTableRepository(Context); + act.Should().Throw(); + } + + [SkippableFact] + public void EntityTableRepository_GoodDbSet_Works() + { + Skip.IfNot(CanRunLiveTests()); + Action act = () => _ = new EntityTableRepository(Context); + act.Should().NotThrow(); + } + + [SkippableFact] + public async Task WrapExceptionAsync_ThrowsConflictException_WhenDbConcurrencyUpdateExceptionThrown() + { + Skip.IfNot(CanRunLiveTests()); + EntityTableRepository repository = await GetPopulatedRepositoryAsync() as EntityTableRepository; + string id = await GetRandomEntityIdAsync(true); + MysqlEntityMovie expectedPayload = await GetEntityAsync(id); + + static Task innerAction() => throw new DbUpdateConcurrencyException("Concurrency exception"); + + Func act = async () => await repository.WrapExceptionAsync(id, innerAction); + (await act.Should().ThrowAsync()).WithStatusCode(409).And.WithPayload(expectedPayload); + } + + [SkippableFact] + public async Task WrapExceptionAsync_ThrowsRepositoryException_WhenDbUpdateExceptionThrown() + { + Skip.IfNot(CanRunLiveTests()); + EntityTableRepository repository = await GetPopulatedRepositoryAsync() as EntityTableRepository; + string id = await GetRandomEntityIdAsync(true); + MysqlEntityMovie expectedPayload = await GetEntityAsync(id); + + static Task innerAction() => throw new DbUpdateException("Non-concurrency exception"); + + Func act = async () => await repository.WrapExceptionAsync(id, innerAction); + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs index 602c268..55d3c34 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs @@ -11,6 +11,7 @@ public class DatabaseFixture { public bool AzureSqlIsInitialized { get; set; } = false; public bool CosmosIsInitialized { get; set; } = false; + public bool MysqlIsInitialized { get; set; } = false; public bool PgIsInitialized { get; set; } = false; } diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs new file mode 100644 index 0000000..83eff51 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.EntityFrameworkCore; +using CommunityToolkit.Datasync.Server.Test.Helpers; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace CommunityToolkit.Datasync.Server.Test.Live; + +[ExcludeFromCodeCoverage] +[Collection("LiveTestsCollection")] +public class MySQL_Controller_Tests : LiveControllerTests +{ + #region Setup + private readonly DatabaseFixture _fixture; + private readonly Random random = new(); + private readonly string connectionString; + private readonly List movies; + + public MySQL_Controller_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base() + { + this._fixture = fixture; + this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_MYSQL_CONNECTIONSTRING"); + if (!string.IsNullOrEmpty(this.connectionString)) + { + output.WriteLine($"MysqlIsInitialized = {this._fixture.MysqlIsInitialized}"); + Context = MysqlDbContext.CreateContext(this.connectionString, output, clearEntities: !this._fixture.MysqlIsInitialized); + this.movies = Context.Movies.AsNoTracking().ToList(); + this._fixture.MysqlIsInitialized = true; + } + } + + private MysqlDbContext Context { get; set; } + + protected override string DriverName { get; } = "PgSQL"; + + protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); + + protected override Task GetEntityAsync(string id) + => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id)); + + protected override Task GetEntityCountAsync() + => Task.FromResult(Context.Movies.Count()); + + protected override Task> GetPopulatedRepositoryAsync() + => Task.FromResult>(new EntityTableRepository(Context)); + + protected override Task GetRandomEntityIdAsync(bool exists) + => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); + #endregion +} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj index a846b3a..ed10564 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj +++ b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs new file mode 100644 index 0000000..bfc135f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace CommunityToolkit.Datasync.TestCommon.Databases; + +[ExcludeFromCodeCoverage] +public class MysqlDbContext(DbContextOptions options) : BaseDbContext(options) +{ + public static MysqlDbContext CreateContext(string connectionString, ITestOutputHelper output = null, bool clearEntities = true) + { + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() + .UseMySql(connectionString: connectionString, serverVersion: ServerVersion.AutoDetect(connectionString), options => options.EnableRetryOnFailure()) + .EnableLogging(output); + MysqlDbContext context = new(optionsBuilder.Options); + + context.InitializeDatabase(clearEntities); + context.PopulateDatabase(); + return context; + } + + internal void InitializeDatabase(bool clearEntities) + { + Database.EnsureCreated(); + + if (clearEntities) + { + ExecuteRawSqlOnEachEntity(@"DELETE FROM {0}"); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(m => m.UpdatedAt) + .ValueGeneratedOnAddOrUpdate(); + + modelBuilder.Entity().Property(m => m.Version) + .IsRowVersion(); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs new file mode 100644 index 0000000..644ef26 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.EntityFrameworkCore; +using CommunityToolkit.Datasync.TestCommon.Models; +using System.ComponentModel.DataAnnotations; + +namespace CommunityToolkit.Datasync.TestCommon.Databases; + +[ExcludeFromCodeCoverage] +public class MysqlEntityMovie : EntityTableData, IMovie, IEquatable +{ + /// + /// True if the movie won the oscar for Best Picture + /// + public bool BestPictureWinner { get; set; } + + /// + /// The running time of the movie + /// + [Required] + [Range(60, 360)] + public int Duration { get; set; } + + /// + /// The MPAA rating for the movie, if available. + /// + public MovieRating Rating { get; set; } = MovieRating.Unrated; + + /// + /// The release date of the movie. + /// + [Required] + public DateOnly ReleaseDate { get; set; } + + /// + /// The title of the movie. + /// + [Required] + [StringLength(128, MinimumLength = 2)] + public string Title { get; set; } = string.Empty; + + /// + /// The year that the movie was released. + /// + [Required] + [Range(1920, 2030)] + public int Year { get; set; } + + /// + /// Determines if this movie has the same content as another movie. + /// + /// The other movie + /// true if the content is the same + public bool Equals(IMovie other) + => other != null + && other.BestPictureWinner == BestPictureWinner + && other.Duration == Duration + && other.Rating == Rating + && other.ReleaseDate == ReleaseDate + && other.Title == Title + && other.Year == Year; +} diff --git a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj index db877fe..f77dbd3 100644 --- a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj +++ b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj @@ -18,5 +18,6 @@ +