using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Api; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api; /// /// R04 S5: the POST's 202 Location points at GET /api/deployments/{id}, which now exists, /// carries the same API-key gate as the POST, and reports the deployment plus its per-node states. /// [Trait("Category", "Unit")] public sealed class DeploymentStatusEndpointTests { private static OtOpcUaConfigDbContext NewDb(string name) => new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); [Fact] public async Task GetDeploymentStatusAsync_returns_the_deployment_and_its_node_states() { var name = Guid.NewGuid().ToString("N"); var id = Guid.NewGuid(); await using (var seed = NewDb(name)) { seed.Deployments.Add(new Deployment { DeploymentId = id, RevisionHash = "rev1", CreatedBy = "ci", Status = DeploymentStatus.Dispatching, ArtifactBlob = new byte[1024], }); seed.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = "other", CreatedBy = "x" }); seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-b", DeploymentId = id, Status = NodeDeploymentStatus.Applying }); seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-a", DeploymentId = id, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow }); await seed.SaveChangesAsync(TestContext.Current.CancellationToken); } await using var db = NewDb(name); var status = await DeployApiEndpoints.GetDeploymentStatusAsync(db, id, TestContext.Current.CancellationToken); status.ShouldNotBeNull(); status!.DeploymentId.ShouldBe(id); status.RevisionHash.ShouldBe("rev1"); status.Status.ShouldBe(nameof(DeploymentStatus.Dispatching)); status.Nodes.Select(n => (n.NodeId, n.Status)).ShouldBe( [("node-a", nameof(NodeDeploymentStatus.Applied)), ("node-b", nameof(NodeDeploymentStatus.Applying))]); } [Fact] public async Task GetDeploymentStatusAsync_returns_null_for_an_unknown_id() { await using var db = NewDb(Guid.NewGuid().ToString("N")); (await DeployApiEndpoints.GetDeploymentStatusAsync(db, Guid.NewGuid(), TestContext.Current.CancellationToken)).ShouldBeNull(); } [Fact] public void Get_route_exists_and_is_gated_like_the_post() { var endpoints = MapDeployApi(); var get = endpoints.Single(e => NameOf(e) == "GetDeployment"); get.RoutePattern.RawText.ShouldBe("/api/deployments/{deploymentId:guid}"); get.Metadata.GetMetadata()!.HttpMethods.ShouldBe(["GET"]); var post = endpoints.Single(e => NameOf(e) == "StartDeployment"); // Both are API-key gated in the handler: AllowAnonymous so the cookie fallback policy does not 401 // the automation client, and no cookie policy of their own. foreach (var e in new[] { get, post }) { e.Metadata.GetMetadata().ShouldNotBeNull(NameOf(e)); e.Metadata.GetOrderedMetadata().Where(a => !string.IsNullOrEmpty(a.Policy)).ShouldBeEmpty(NameOf(e)); } } [Fact] public void Accepted_location_matches_the_get_route() { var id = Guid.NewGuid(); var result = DeployApiEndpoints.ToResult(new StartDeploymentResult( StartDeploymentOutcome.Accepted, new DeploymentId(id), new RevisionHash("rev"), null, CorrelationId.NewId())); var accepted = result.ShouldBeOfType>(); accepted.Location.ShouldBe($"/api/deployments/{id}"); Guid.TryParse(accepted.Location!["/api/deployments/".Length..], out _).ShouldBeTrue(); } private static string? NameOf(Endpoint e) => e.Metadata.GetMetadata()?.EndpointName; private static IReadOnlyList MapDeployApi() { var builder = WebApplication.CreateBuilder(); // Registered only so the handlers' service parameters are inferred as services; the metadata // scan below never invokes a handler, so nothing is resolved. builder.Services.AddSingleton(_ => null!); builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase("deploy-api-metadata")); var app = builder.Build(); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { [DeployApiEndpoints.ConfigKey] = "k" }) .Build(); app.MapOtOpcUaDeployApi(config); return ((IEndpointRouteBuilder)app).DataSources .SelectMany(d => d.Endpoints) .OfType() .ToList(); } }