217 lines
9.8 KiB
C#
217 lines
9.8 KiB
C#
using System.Security.Claims;
|
|
using Bunit;
|
|
using Bunit.TestDoubles;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Rendering;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
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;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
|
using InstanceCreatePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceCreate;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
|
|
|
/// <summary>
|
|
/// bUnit tests for the Create Instance template field: a read-only path box plus a
|
|
/// Choose… button that opens <c>TemplatePickerDialog</c>, and the <c>?templateId=</c>
|
|
/// query preselect (including the dangling-id hint and the no-templates empty state).
|
|
/// </summary>
|
|
public class InstanceCreateTemplateFieldTests : BunitContext
|
|
{
|
|
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
|
private readonly IAuditService _audit = Substitute.For<IAuditService>();
|
|
private readonly DialogService _dialog = new();
|
|
|
|
public InstanceCreateTemplateFieldTests()
|
|
{
|
|
Services.AddSingleton(_repo);
|
|
Services.AddSingleton(_siteRepo);
|
|
Services.AddSingleton(_deployRepo);
|
|
Services.AddSingleton(_pipeline);
|
|
Services.AddSingleton(_audit);
|
|
// The Connection Bindings card renders NodeBrowserDialog once a site is chosen.
|
|
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.CentralUI.Services.IBrowseService>());
|
|
|
|
// DeploymentService / InstanceService have non-mockable concrete deps;
|
|
// instantiate them directly (same block TopologyPageTests uses).
|
|
Services.AddSingleton(new CommunicationService(
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<CommunicationService>.Instance));
|
|
Services.AddSingleton(new OperationLockManager());
|
|
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
|
|
{
|
|
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
|
}));
|
|
Services.AddSingleton(Options.Create(new CommunicationOptions()));
|
|
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
|
|
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
|
|
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
|
|
Services.AddSingleton<IDeploymentStatusNotifier>(
|
|
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance));
|
|
Services.AddSingleton(Substitute.For<IStaleInstanceProbe>());
|
|
Services.AddScoped<DeploymentService>();
|
|
Services.AddScoped<AreaService>();
|
|
Services.AddScoped<InstanceService>();
|
|
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
|
|
|
// A real DialogService behind a real DialogHost so Choose… actually opens.
|
|
Services.AddSingleton<IDialogService>(_dialog);
|
|
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Deployer")
|
|
};
|
|
Services.AddSingleton<AuthenticationStateProvider>(
|
|
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
|
|
Services.AddAuthorizationCore();
|
|
Services.AddTestConfigurationMutations();
|
|
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
}
|
|
|
|
/// <summary>Folders A → B with template 7 "Deep"; folder Other with template 8 "Elsewhere".</summary>
|
|
private void SeedCatalogue(bool empty = false, bool withSite = false)
|
|
{
|
|
var folders = empty
|
|
? new List<TemplateFolder>()
|
|
: new List<TemplateFolder>
|
|
{
|
|
new("A") { Id = 1 },
|
|
new("B") { Id = 2, ParentFolderId = 1 },
|
|
new("Other") { Id = 3 },
|
|
};
|
|
var templates = empty
|
|
? new List<Template>()
|
|
: new List<Template>
|
|
{
|
|
new("Deep") { Id = 7, FolderId = 2 },
|
|
new("Elsewhere") { Id = 8, FolderId = 3 },
|
|
};
|
|
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(folders));
|
|
|
|
var sites = withSite
|
|
? new List<Site> { new("Site A", "site-a") { Id = 1 } }
|
|
: new List<Site>();
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
|
|
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Area>>(new List<Area>()));
|
|
|
|
_pipeline.PreviewAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(Result<FlattenedConfiguration>.Success(new FlattenedConfiguration())));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the page and a DialogHost side by side (as MainLayout does) after seeding
|
|
/// the query string. <c>[SupplyParameterFromQuery]</c> properties are cascading-only,
|
|
/// so they cannot be Add()-ed as ordinary parameters — the URL is the only way in.
|
|
/// </summary>
|
|
private IRenderedComponent<TestHostWrapper> RenderPage(int? templateId = null, int? siteId = null)
|
|
{
|
|
var query = new List<string>();
|
|
if (templateId is int t) query.Add($"templateId={t}");
|
|
if (siteId is int s) query.Add($"siteId={s}");
|
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
|
nav.NavigateTo("/deployment/instances/create" + (query.Count > 0 ? "?" + string.Join("&", query) : ""));
|
|
return Render<TestHostWrapper>();
|
|
}
|
|
|
|
/// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary>
|
|
public sealed class TestHostWrapper : ComponentBase
|
|
{
|
|
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
|
{
|
|
builder.OpenComponent<InstanceCreatePage>(0);
|
|
builder.CloseComponent();
|
|
builder.OpenComponent<DialogHost>(1);
|
|
builder.CloseComponent();
|
|
}
|
|
}
|
|
|
|
private static void ExpandFolder(IRenderedComponent<TestHostWrapper> cut, string label) =>
|
|
cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
|
|
.QuerySelector(".tv-toggle")!.Click();
|
|
|
|
[Fact]
|
|
public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
|
|
{
|
|
SeedCatalogue();
|
|
var cut = RenderPage();
|
|
var field = cut.Find("input[data-test='create-template']");
|
|
Assert.True(field.HasAttribute("readonly"));
|
|
Assert.Equal("Choose a template…", field.GetAttribute("placeholder"));
|
|
Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
|
|
}
|
|
|
|
[Fact]
|
|
public void TemplateIdQuery_PrefillsFieldWithFolderPath()
|
|
{
|
|
SeedCatalogue();
|
|
var cut = RenderPage(templateId: 7);
|
|
Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
|
|
{
|
|
SeedCatalogue();
|
|
var cut = RenderPage(templateId: 999);
|
|
Assert.Equal("", cut.Find("input[data-test='create-template']").GetAttribute("value") ?? "");
|
|
Assert.Contains("Template 999 no longer exists", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
|
|
{
|
|
SeedCatalogue(empty: true);
|
|
var cut = RenderPage();
|
|
Assert.True(cut.Find("button[data-test='create-template-choose']").HasAttribute("disabled"));
|
|
Assert.Contains("/design/templates", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
|
|
{
|
|
SeedCatalogue(withSite: true);
|
|
var cut = RenderPage(siteId: 1);
|
|
|
|
cut.Find("button[data-test='create-template-choose']").Click();
|
|
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("button[data-test='picker-select']")));
|
|
|
|
ExpandFolder(cut, "A");
|
|
ExpandFolder(cut, "B");
|
|
cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == "Deep").Click();
|
|
cut.Find("button[data-test='picker-select']").Click();
|
|
|
|
cut.WaitForAssertion(() => Assert.Equal(
|
|
"A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value")));
|
|
await _pipeline.Received(1).PreviewAsync(7, 1, Arg.Any<CancellationToken>());
|
|
}
|
|
}
|