using System.Security.Claims;
using System.Security.Cryptography;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
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.Security;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
///
/// bUnit + logic tests for the TransportImport wizard.
///
///
/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
/// Selecting a file via InputFile is hard to drive cleanly from bUnit
/// (JS interop + DotNetStreamReference), so the state-machine tests reach into
/// the page instance via cut.Instance and the InternalsVisibleTo
/// declaration on ZB.MOM.WW.ScadaBridge.CentralUI.csproj. The BundleImporter
/// mock controls every load/preview/apply contract so each step's behaviour can
/// be exercised in isolation. The full happy-path round-trip is covered by the
/// integration tests in ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.
///
///
public class TransportImportPageTests : BunitContext
{
private readonly IBundleImporter _importer = Substitute.For();
private readonly IAuditService _auditService = Substitute.For();
// The Map step injects ISiteRepository to populate target-site +
// target-connection dropdowns. Register a substitute so every test (not just
// the Map tests) can render the wizard without a missing-service failure.
private readonly ISiteRepository _siteRepo = Substitute.For();
/// Client address the wizard reports for the per-IP unlock throttle (register row 44).
private const string TestClientIp = "203.0.113.7";
/// Initializes the test context with stubbed importer, audit, repository and authentication services.
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddSingleton(_importer);
Services.AddSingleton(_auditService);
// Register row 44: the wizard passes the circuit's client IP to LoadAsync so
// the per-IP unlock throttle can attribute the attempt. bUnit has no HTTP
// context, so use the provider's explicit-address test seam.
Services.AddSingleton(new CircuitClientAddressProvider(TestClientIp));
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(Array.Empty());
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any())
.Returns(Array.Empty());
Services.AddSingleton(_siteRepo);
Services.AddSingleton>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
MaxBundleSizeMb = 10,
MaxUnlockAttemptsPerSession = 3,
}));
// Provide a SQLite in-memory ScadaBridgeDbContext so the page's
// DbContext.SaveChangesAsync() calls in the audit path succeed.
var dbOptions = new DbContextOptionsBuilder()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
.Options;
var dbContext = new ScadaBridgeDbContext(dbOptions);
dbContext.Database.OpenConnection();
dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext);
var principal = BuildPrincipal("alice", "Administrator");
Services.AddSingleton(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
{
var claims = new List { new(JwtTokenService.UsernameClaimType, username) };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private static BundleSession BuildEncryptedSession(string sourceEnv = "prod-cluster") =>
new()
{
SessionId = Guid.NewGuid(),
Manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: sourceEnv,
ExportedBy: "bob",
ScadaBridgeVersion: "1.0.0",
ContentHash: "sha256:0000",
Encryption: new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty()),
DecryptedContent = Array.Empty(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
};
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the InputFile upload control.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that the wizard renders the step 1 upload input.
[Fact]
public void Renders_step1_upload_input()
{
var cut = Render();
// Bootstrap classes are applied by InputFile via the CSS class attribute.
Assert.NotNull(cut.Find("input[type='file']"));
// The Bootstrap step indicator should highlight Step 1.
Assert.Contains("Upload", cut.Markup);
}
// ─────────────────────────────────────────────────────────────────────
// Test 2: Wrong passphrase increments the failure counter without
// advancing past Step 2.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that a decryption failure increments the passphrase attempt counter.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Decryption_failure_increments_attempt_counter()
{
// Set up the importer to throw CryptographicException for wrong passphrases.
_importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Throws(new CryptographicException("authentication tag mismatch"));
var cut = Render();
await cut.InvokeAsync(() =>
{
// Seed the wizard at the passphrase step with cached bytes.
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
SetField(cut.Instance, "_passphrase", "wrong-pass");
});
// Drive a passphrase submission.
await cut.InvokeAsync(async () =>
{
await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
});
Assert.Equal(1, GetField(cut.Instance, "_failedUnlockAttempts"));
Assert.Equal(
TransportImportPage.ImportWizardStep.Passphrase,
GetField(cut.Instance, "_step"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns
// to Step 1 with an explanatory error.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that three failed unlock attempts force the user to upload the bundle again.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Three_failed_unlocks_force_reupload()
{
_importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Throws(new CryptographicException("authentication tag mismatch"));
var cut = Render();
await cut.InvokeAsync(() =>
{
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
});
for (var i = 0; i < 3; i++)
{
await cut.InvokeAsync(async () =>
{
SetField(cut.Instance, "_passphrase", $"wrong-{i}");
await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
});
}
Assert.Equal(
TransportImportPage.ImportWizardStep.Upload,
GetField(cut.Instance, "_step"));
var errorMessage = GetField(cut.Instance, "_errorMessage");
Assert.NotNull(errorMessage);
Assert.Contains("Too many failed unlock attempts", errorMessage);
}
// ─────────────────────────────────────────────────────────────────────
// Test 4: Confirm step requires an exact match (case-sensitive) on the
// source environment name before Apply is enabled.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that the confirm step requires an exact environment name match.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Confirm_step_requires_exact_environment_name_match()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
_importer.PreviewAsync(session.SessionId, Arg.Any())
.Returns(new ImportPreview(session.SessionId, new List
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
}));
var cut = Render();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
}));
SetField(cut.Instance, "_resolutions", new Dictionary<(string EntityType, string Name), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
});
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
});
// Wrong text → Apply button is disabled.
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "wrong"));
cut.Render();
var applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
Assert.True(applyBtn.HasAttribute("disabled"));
// Case mismatch → still disabled.
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "PROD-CLUSTER"));
cut.Render();
applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
Assert.True(applyBtn.HasAttribute("disabled"));
// Exact match → enabled.
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
cut.Render();
applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
Assert.False(applyBtn.HasAttribute("disabled"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 5: ApplyAsync is invoked with the chosen resolutions and the
// authenticated user identity.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that the apply step calls the bundle importer's ApplyAsync with the chosen resolutions.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var resolutions = new Dictionary<(string EntityType, string Name), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Overwrite, null),
};
var expectedResult = new ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0,
Overwritten: 1,
Skipped: 0,
Renamed: 0,
StaleInstanceIds: Array.Empty(),
AuditEventCorrelation: Guid.NewGuid().ToString());
_importer.ApplyAsync(
session.SessionId,
Arg.Any>(),
"alice",
Arg.Any(),
Arg.Any())
.Returns(expectedResult);
var cut = Render();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, null, null),
}));
SetField(cut.Instance, "_resolutions", resolutions);
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
});
await cut.InvokeAsync(async () =>
{
await InvokeAsyncMethod(cut.Instance, "ApplyAsync");
});
await _importer.Received(1).ApplyAsync(
session.SessionId,
Arg.Is>(rs =>
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
&& r.Action == ResolutionAction.Overwrite)),
"alice",
Arg.Any(),
Arg.Any());
Assert.Equal(
TransportImportPage.ImportWizardStep.Result,
GetField(cut.Instance, "_step"));
Assert.Equal(expectedResult, GetField(cut.Instance, "_result"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 6: A user without the Admin role fails the RequireAdmin policy.
// The router enforces [Authorize(Policy=...)] at request time — bUnit
// doesn't model routing, so we verify the policy itself denies the
// principal.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that the page is unauthorized for a user without the Admin role.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Admin_role()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeAuthorization();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService();
// Design-only user — has a role but it isn't Admin.
var principal = BuildPrincipal("bob", "Designer");
var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.RequireAdmin);
Assert.False(result.Succeeded);
}
// ─────────────────────────────────────────────────────────────────────
// Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the
// expected default ResolutionAction.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that default resolutions map each conflict kind to the expected action.
[Fact]
public void BuildDefaultResolutions_maps_kinds_to_actions()
{
var preview = new ImportPreview(Guid.NewGuid(), new List
{
new("Template", "A", 1, 1, ConflictKind.Identical, null, null),
new("Template", "B", null, 1, ConflictKind.New, null, null),
new("Template", "C", 1, 2, ConflictKind.Modified, null, null),
new("Reference", "D", null, null, ConflictKind.Blocker, null, "missing dep"),
});
var map = TransportImportPage.BuildDefaultResolutions(preview);
Assert.Equal(ResolutionAction.Skip, map[("Template", "A")].Action);
Assert.Equal(ResolutionAction.Add, map[("Template", "B")].Action);
Assert.Equal(ResolutionAction.Overwrite, map[("Template", "C")].Action);
Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
}
// ─────────────────────────────────────────────────────────────────────
// Test 8: the Map sub-section renders one row per required site /
// connection mapping with the auto-match defaults pre-selected, and the
// section is hidden entirely when the preview carries no required mappings.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that the map section renders the required rows with auto-match defaults selected.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Map_section_renders_required_rows_with_automatch_defaults()
{
// Target environment has one site (site-b) with one connection (opc-main),
// which the bundle's source site "site-a" / connection "opc-a" auto-match to.
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(new List { new("Site B", "site-b") { Id = 7 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any())
.Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } });
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List(),
new List { new("site-a", "Site A", "site-b") },
new List { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
var cut = Render();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
// Section present with one site row and one connection row.
Assert.NotNull(cut.Find("[data-testid='map-section']"));
Assert.Single(cut.FindAll("[data-testid='map-site-row']"));
Assert.Single(cut.FindAll("[data-testid='map-conn-row']"));
// Auto-match defaults: site-a → site-b, opc-a → opc-main.
var siteSelect = cut.Find("[data-testid='map-site-select-site-a']");
var selectedSiteOpt = siteSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
Assert.Equal("site-b", selectedSiteOpt.GetAttribute("value"));
var connSelect = cut.Find("[data-testid='map-conn-select-site-a-opc-a']");
var selectedConnOpt = connSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
}
/// Verifies that the map section is hidden when no mappings are required.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Map_section_hidden_when_no_required_mappings()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
var cut = Render();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
Assert.Empty(cut.FindAll("[data-testid='map-section']"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 9: the operator's Map choices fold into the BundleNameMap
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
// CreateNew. Captured via the substituted IBundleImporter.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that apply passes a BundleNameMap built from the operator's map choices.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
{
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(new List { new("Site B", "site-b") { Id = 7 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any())
.Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } });
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List(),
// site-a auto-matches site-b; site-c has no auto-match (→ Create new default).
new List
{
new("site-a", "Site A", "site-b"),
new("site-c", "Site C", null),
},
// opc-a (under site-a) auto-matches opc-main.
new List { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
var expectedResult = new ImportResult(
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty(), Guid.NewGuid().ToString());
_importer.ApplyAsync(
session.SessionId,
Arg.Any>(),
"alice",
Arg.Any(),
Arg.Any())
.Returns(expectedResult);
var cut = Render();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
await _importer.Received(1).ApplyAsync(
session.SessionId,
Arg.Any>(),
"alice",
Arg.Any(),
Arg.Is(m =>
m.Sites.Count == 2
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-a"
&& s.Action == MappingAction.MapToExisting && s.TargetSiteIdentifier == "site-b")
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-c"
&& s.Action == MappingAction.CreateNew && s.TargetSiteIdentifier == null)
&& m.Connections.Count == 1
&& m.Connections.Any(c => c.SourceSiteIdentifier == "site-a"
&& c.SourceConnectionName == "opc-a"
&& c.Action == MappingAction.MapToExisting && c.TargetConnectionName == "opc-main")));
}
// ─────────────────────────────────────────────────────────────────────
// Test 10: a Modified row whose FieldDiffJson carries a code
// lineDiff renders +/- lines via LineDiffView.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that a modified row with a code line diff renders added and removed lines.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
{
const string fieldDiffJson = """
{
"changes": [
{
"field": "Code",
"oldValue": "a\nb\nc",
"newValue": "a\nB\nc",
"lineDiff": {
"hunks": [
{ "op": "context", "text": "a", "oldLineNo": 1, "newLineNo": 1 },
{ "op": "remove", "text": "b", "oldLineNo": 2 },
{ "op": "add", "text": "B", "newLineNo": 2 },
{ "op": "context", "text": "c", "oldLineNo": 3, "newLineNo": 3 }
],
"truncated": false,
"addedCount": 1,
"removedCount": 1
}
}
]
}
""";
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
var cut = Render();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
// The code line-diff block is present and shows one add + one remove line.
Assert.NotNull(cut.Find("[data-testid='code-line-diff']"));
Assert.NotNull(cut.Find("[data-testid='line-diff']"));
Assert.Single(cut.FindAll("[data-testid='line-diff-add']"));
Assert.Single(cut.FindAll("[data-testid='line-diff-remove']"));
// The raw JSON
fallback is NOT used for code-field diffs.
Assert.DoesNotContain("\"lineDiff\"", cut.Find("[data-testid='code-line-diff']").InnerHtml);
// No truncation marker for a complete diff.
Assert.Empty(cut.FindAll("[data-testid='line-diff-truncated']"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 11: truncation marker shows when the lineDiff is truncated.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that a modified row with a truncated line diff shows the truncation marker.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
{
const string fieldDiffJson = """
{
"changes": [
{
"field": "Code",
"oldValue": "x",
"newValue": "y",
"lineDiff": {
"hunks": [
{ "op": "remove", "text": "x", "oldLineNo": 1 },
{ "op": "add", "text": "y", "newLineNo": 1 }
],
"truncated": true,
"addedCount": 12,
"removedCount": 8
}
}
]
}
""";
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
var cut = Render();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
var marker = cut.Find("[data-testid='line-diff-truncated']");
Assert.Contains("truncated", marker.TextContent);
Assert.Contains("+12", marker.TextContent);
Assert.Contains("-8", marker.TextContent);
}
// ─────────────────────────────────────────────────────────────────────
// Test 12: changing a source site's dropdown to a target
// that lacks the previously auto-matched connection name resets that
// connection choice to CreateNew (""), and BuildNameMap therefore emits
// a CreateNew entry — not a MapToExisting for an absent connection.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that changing a site choice resets connection choices the new target site lacks.
/// A task that represents the asynchronous operation.
[Fact]
public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection()
{
// Target environment: site-b has connection "opc-main"; site-c has NO connections.
var siteB = new Site("Site B", "site-b") { Id = 7 };
var siteC = new Site("Site C", "site-c") { Id = 8 };
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(new List { siteB, siteC });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any())
.Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(8, Arg.Any())
.Returns(Array.Empty());
// Bundle: source site "site-a" auto-matches site-b; connection "opc-a" auto-matches "opc-main".
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List(),
new List { new("site-a", "Site A", "site-b") },
new List { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
var cut = Render();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
// Drive LoadPreviewAndAdvanceAsync to seed auto-match state (site-a → site-b, opc-a → opc-main).
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
// Verify the auto-match seed: connection choice is "opc-main".
var connChoicesBefore = GetField>(
cut.Instance, "_connectionChoices");
Assert.Equal("opc-main", connChoicesBefore[("site-a", "opc-a")]);
// Operator changes site-a's target from site-b → site-c (which has no connections).
await cut.InvokeAsync(async () =>
{
var method = cut.Instance.GetType().GetMethod(
"OnSiteChoiceChangedAsync",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await (Task)method.Invoke(cut.Instance, new object[] { "site-a", "site-c" })!;
});
// Connection choice for (site-a, opc-a) must now be CreateNew ("").
var connChoicesAfter = GetField>(
cut.Instance, "_connectionChoices");
Assert.Equal(string.Empty, connChoicesAfter[("site-a", "opc-a")]);
// BuildNameMap must NOT emit MapToExisting for "opc-a" — it should emit CreateNew.
var nameMap = TransportImportPageTests.InvokeBuildNameMap(cut.Instance);
var connMapping = nameMap.Connections.Single(c => c.SourceConnectionName == "opc-a");
Assert.Equal(MappingAction.CreateNew, connMapping.Action);
Assert.Null(connMapping.TargetConnectionName);
}
// ─────────────────────────────────────────────────────────────────────
// Test 13: BackToUpload clears session/preview/Map state
// so a subsequent re-upload flow cannot inherit stale data.
// ─────────────────────────────────────────────────────────────────────
/// Verifies that going back to upload clears the session, preview and map state.
/// A task that represents the asynchronous operation.
[Fact]
public async Task BackToUpload_clears_session_and_preview_state()
{
// Seed the wizard at the Diff step with non-trivial session + preview + Map state.
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
},
new List { new("site-a", "Site A", null) },
new List { new("site-a", "conn-x", null) });
var cut = Render();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", preview);
SetField(cut.Instance, "_resolutions",
new Dictionary<(string, string), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
});
SetField(cut.Instance, "_siteChoices",
new Dictionary { ["site-a"] = "site-b" });
SetField(cut.Instance, "_connectionChoices",
new Dictionary<(string, string), string> { [("site-a", "conn-x")] = "conn-y" });
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
});
// Invoke BackToUpload.
await cut.InvokeAsync(() =>
{
var method = cut.Instance.GetType().GetMethod(
"BackToUpload",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
method.Invoke(cut.Instance, Array.Empty