Skip to content

Commit

Permalink
(#215) MySQL test support. (#222)
Browse files Browse the repository at this point in the history
* (#215) Added MySQL to list of resources.

* (#215) MySQL Test Suite

* (#215) Use Pomelo package instead of official MySQL package.

* (#215) Enable connection retry on failure
  • Loading branch information
adrianhall authored Jan 22, 2025
1 parent b155848 commit b7b68ff
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 3 deletions.
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.2.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.2.efcore.9.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageVersion Include="System.Formats.Asn1" Version="9.0.1" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
Expand Down
3 changes: 2 additions & 1 deletion infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
89 changes: 89 additions & 0 deletions infra/modules/mysql.bicep
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 16 additions & 1 deletion infra/resources.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions infra/scripts/write-runsettings.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ $fileContents = @"
<EnvironmentVariables>
<DATASYNC_AZSQL_CONNECTIONSTRING>$($outputs.AZSQL_CONNECTION_STRING)</DATASYNC_AZSQL_CONNECTIONSTRING>
<DATASYNC_COSMOS_CONNECTIONSTRING>$($outputs.COSMOS_CONNECTION_STRING)</DATASYNC_COSMOS_CONNECTIONSTRING>
<DATASYNC_MYSQL_CONNECTIONSTRING>$($outputs.MYSQL_CONNECTION_STRING)</DATASYNC_MYSQL_CONNECTIONSTRING>
<DATASYNC_PGSQL_CONNECTIONSTRING>$($outputs.PGSQL_CONNECTION_STRING)</DATASYNC_PGSQL_CONNECTIONSTRING>
<DATASYNC_SERVICE_ENDPOINT>$($outputs.SERVICE_ENDPOINT)</DATASYNC_SERVICE_ENDPOINT>
<ENABLE_SQL_LOGGING>true</ENABLE_SQL_LOGGING>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ public class EntityTableData : BaseEntityTableData

/// <inheritdoc />
[Timestamp]
public override byte[] Version { get; set; } = Array.Empty<byte>();
public override byte[] Version { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -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<MysqlEntityMovie>
{
#region Setup
private readonly DatabaseFixture _fixture;
private readonly Random random = new();
private readonly string connectionString;
private readonly List<MysqlEntityMovie> movies;
private readonly Lazy<MysqlDbContext> _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>(() => 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<MysqlEntityMovie> GetEntityAsync(string id)
=> Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));

protected override Task<int> GetEntityCountAsync()
=> Task.FromResult(Context.Movies.Count());

protected override Task<IRepository<MysqlEntityMovie>> GetPopulatedRepositoryAsync()
=> Task.FromResult<IRepository<MysqlEntityMovie>>(new EntityTableRepository<MysqlEntityMovie>(Context));

protected override Task<string> 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<EntityTableData>(Context);
act.Should().Throw<ArgumentException>();
}

[SkippableFact]
public void EntityTableRepository_GoodDbSet_Works()
{
Skip.IfNot(CanRunLiveTests());
Action act = () => _ = new EntityTableRepository<MysqlEntityMovie>(Context);
act.Should().NotThrow();
}

[SkippableFact]
public async Task WrapExceptionAsync_ThrowsConflictException_WhenDbConcurrencyUpdateExceptionThrown()
{
Skip.IfNot(CanRunLiveTests());
EntityTableRepository<MysqlEntityMovie> repository = await GetPopulatedRepositoryAsync() as EntityTableRepository<MysqlEntityMovie>;
string id = await GetRandomEntityIdAsync(true);
MysqlEntityMovie expectedPayload = await GetEntityAsync(id);

static Task innerAction() => throw new DbUpdateConcurrencyException("Concurrency exception");

Func<Task> act = async () => await repository.WrapExceptionAsync(id, innerAction);
(await act.Should().ThrowAsync<HttpException>()).WithStatusCode(409).And.WithPayload(expectedPayload);
}

[SkippableFact]
public async Task WrapExceptionAsync_ThrowsRepositoryException_WhenDbUpdateExceptionThrown()
{
Skip.IfNot(CanRunLiveTests());
EntityTableRepository<MysqlEntityMovie> repository = await GetPopulatedRepositoryAsync() as EntityTableRepository<MysqlEntityMovie>;
string id = await GetRandomEntityIdAsync(true);
MysqlEntityMovie expectedPayload = await GetEntityAsync(id);

static Task innerAction() => throw new DbUpdateException("Non-concurrency exception");

Func<Task> act = async () => await repository.WrapExceptionAsync(id, innerAction);
await act.Should().ThrowAsync<RepositoryException>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<MysqlEntityMovie>
{
#region Setup
private readonly DatabaseFixture _fixture;
private readonly Random random = new();
private readonly string connectionString;
private readonly List<MysqlEntityMovie> 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<MysqlEntityMovie> GetEntityAsync(string id)
=> Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));

protected override Task<int> GetEntityCountAsync()
=> Task.FromResult(Context.Movies.Count());

protected override Task<IRepository<MysqlEntityMovie>> GetPopulatedRepositoryAsync()
=> Task.FromResult<IRepository<MysqlEntityMovie>>(new EntityTableRepository<MysqlEntityMovie>(Context));

protected override Task<string> GetRandomEntityIdAsync(bool exists)
=> Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Spatial" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MysqlDbContext> options) : BaseDbContext<MysqlDbContext, MysqlEntityMovie>(options)
{
public static MysqlDbContext CreateContext(string connectionString, ITestOutputHelper output = null, bool clearEntities = true)
{
if (string.IsNullOrEmpty(connectionString))
{
throw new ArgumentNullException(nameof(connectionString));
}

DbContextOptionsBuilder<MysqlDbContext> optionsBuilder = new DbContextOptionsBuilder<MysqlDbContext>()
.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<MysqlEntityMovie>().Property(m => m.UpdatedAt)
.ValueGeneratedOnAddOrUpdate();

modelBuilder.Entity<MysqlEntityMovie>().Property(m => m.Version)
.IsRowVersion();

base.OnModelCreating(modelBuilder);
}
}
Loading

0 comments on commit b7b68ff

Please sign in to comment.