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;
}
}