using System.Data.Common; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Import; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// Regression guard for Gitea #28: must /// run its multi-SaveChanges apply through the context's execution /// strategy, because the production central ScadaBridgeDbContext is /// configured with EnableRetryOnFailure and /// SqlServerRetryingExecutionStrategy REJECTS a user-initiated /// transaction opened outside the strategy /// (CoreStrings.ExecutionStrategyExistingTransaction) — so before the /// fix every bundle import threw on any real SQL Server. /// /// The rest of the Transport suite runs on the in-memory provider, which has NO /// retrying strategy and where BeginTransactionAsync is a no-op, so it /// could never surface this. Here we run on SQLite (real relational /// transactions) AND configure a retrying execution strategy whose only job is /// to have RetriesOnFailure == true, which arms the exact same guard as /// the production SQL Server strategy. If ApplyAsync opened the /// transaction directly, this test would throw before importing anything. /// /// /// Reuses defined alongside /// in this assembly. /// /// public sealed class BundleImporterRetryingStrategyTests : IDisposable { private readonly ServiceProvider _provider; private readonly DbConnection _sharedConnection; public BundleImporterRetryingStrategyTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); // SQLite :memory: is per-connection, so pin one open connection for the // whole fixture or the schema vanishes between DbContext instances. _sharedConnection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); _sharedConnection.Open(); services.AddSingleton(new EphemeralDataProtectionProvider()); services.AddSingleton(sp => { var builder = new DbContextOptionsBuilder(); // Configure a RETRYING execution strategy — this is the crux of the // regression. RetriesOnFailure == true (MaxRetryCount > 0) makes EF // reject any user-initiated BeginTransaction opened outside // strategy.ExecuteAsync, exactly like the production SQL Server // EnableRetryOnFailure configuration. builder.UseSqlite( _sharedConnection, sqlite => sqlite.ExecutionStrategy(deps => new TestRetryingExecutionStrategy(deps))); builder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); return builder.Options; }); services.AddScoped(sp => new SqliteCompatibleScadaBridgeDbContext( sp.GetRequiredService>(), sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); // IStaleInstanceProbe (DeploymentManager) + flatten/hash (TemplateEngine) // so ApplyAsync's StaleInstanceIds computation resolves a real probe. services.AddTemplateEngine(); services.AddDeploymentManager(); _provider = services.BuildServiceProvider(); using var scope = _provider.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Database.EnsureCreated(); } public void Dispose() { _provider.Dispose(); _sharedConnection.Dispose(); } [Fact] public async Task ApplyAsync_succeeds_under_a_retrying_execution_strategy() { // Arrange: seed → export → wipe → apply(Add). Same shape as // BundleImporterApplyTests.ApplyAsync_adds_new_artifacts_in_single_transaction, // but the fixture's context carries a retrying execution strategy. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Act: before the fix this threw CoreStrings.ExecutionStrategyExistingTransaction // ("... does not support user-initiated transactions.") from the direct // BeginTransactionAsync. With the fix the apply runs inside the strategy. ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var resolutions = new List { new("Template", "Pump", ResolutionAction.Add, null), new("SharedScript", "HelperFn", ResolutionAction.Add, null), }; result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } // Assert: the whole transactional apply committed. Assert.Equal(2, result.Added); Assert.Equal(0, result.Overwritten); Assert.NotEqual(Guid.Empty, result.BundleImportId); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump")); Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn")); // The BundleImported audit row landed inside the committed transaction. Assert.Equal(1, await ctx.AuditLogEntries.CountAsync(a => a.Action == "BundleImported")); } } // ---- helpers (mirror BundleImporterApplyTests) ---- private async Task ExportAndLoadAsync() { Stream bundleStream; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: sharedScriptIds, ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } using var ms = new MemoryStream(); await bundleStream.CopyToAsync(ms); ms.Position = 0; await using var loadScope = _provider.CreateAsyncScope(); var importer = loadScope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(ms, passphrase: null); return session.SessionId; } private async Task WipeContentAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.RemoveRange(ctx.Templates); ctx.SharedScripts.RemoveRange(ctx.SharedScripts); ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders); await ctx.SaveChangesAsync(); } /// /// A minimal retrying execution strategy for the test. Its sole purpose is /// to report RetriesOnFailure == true (inherited from /// , since MaxRetryCount > 0), which /// arms EF's user-initiated-transaction guard just like the production /// SqlServerRetryingExecutionStrategy. It never actually retries — /// returns false — so a genuine fault surfaces /// immediately instead of looping. /// private sealed class TestRetryingExecutionStrategy : ExecutionStrategy { public TestRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies) : base(dependencies, maxRetryCount: 3, maxRetryDelay: TimeSpan.FromMilliseconds(1)) { } protected override bool ShouldRetryOn(Exception exception) => false; } }