using System.Net; using System.Net.Http.Json; using System.Text.Json; using DongfangHydro.Dashboard.Api.Data; using DongfangHydro.Dashboard.Api.Contracts; using DongfangHydro.Dashboard.Api.Realtime; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Xunit; namespace DongfangHydro.Dashboard.Tests; public sealed class DashboardApiTests(DashboardApiFactory factory) : IClassFixture { [Fact] public async Task Orders_SearchesSeededWaterConservancyProductNames() { using var client = factory.CreateClient(); var response = await client.GetAsync("/api/orders?keyword=门架&page=1&pageSize=20"); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(body.GetProperty("total").GetInt32() >= 1); Assert.Contains( body.GetProperty("items").EnumerateArray(), item => item.GetProperty("productName").GetString()!.Contains("门架", StringComparison.Ordinal)); } [Fact] public async Task OrderTree_ReturnsTheCompleteSeededHierarchy() { using var client = factory.CreateClient(); var orders = await client.GetFromJsonAsync("/api/orders?page=1&pageSize=20"); var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString(); var root = await client.GetFromJsonAsync($"/api/orders/{orderId}/tree"); Assert.Equal("order", root.GetProperty("nodeType").GetString()); Assert.Equal(66, CountNodes(root)); Assert.Equal(1, CountNodes(root, "order")); Assert.Equal(5, CountNodes(root, "part")); Assert.Equal(15, CountNodes(root, "component")); Assert.Equal(45, CountNodes(root, "process")); Assert.False(string.IsNullOrWhiteSpace(FindFirstProcess(root).GetProperty("version").GetString())); } [Fact] public async Task UpdateProgress_RecalculatesTheOrderAndReturnsTheUpdatedNode() { using var client = factory.CreateClient(); factory.UpdateQueue.Reset(); var orders = await client.GetFromJsonAsync("/api/orders?page=1&pageSize=20"); var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString(); var root = await client.GetFromJsonAsync($"/api/orders/{orderId}/tree"); var process = FindFirstProcess(root); var response = await client.PatchAsJsonAsync( $"/api/nodes/{process.GetProperty("id").GetString()}/progress", new { completedQty = process.GetProperty("requiredQty").GetInt32(), expectedVersion = process.GetProperty("version").GetString(), status = "done", riskLevel = "normal", delayDays = 0, delayReason = string.Empty, }); var updated = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(100, updated.GetProperty("node").GetProperty("progress").GetInt32()); Assert.Equal("done", updated.GetProperty("node").GetProperty("status").GetString()); Assert.Equal(orderId, updated.GetProperty("order").GetProperty("id").GetString()); Assert.Equal(1, factory.UpdateQueue.EnqueueCount); } [Fact] public async Task UpdateProgress_RejectsARequestWithoutCompletedQuantity() { using var client = factory.CreateClient(); var process = await GetFirstProcessAsync(client); var response = await client.PatchAsJsonAsync( $"/api/nodes/{process.GetProperty("id").GetString()}/progress", new { status = "in_progress" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task UpdateProgress_RejectsDoneStatusBeforeThePlannedQuantityIsComplete() { using var client = factory.CreateClient(); var process = await GetFirstProcessAsync(client); var requiredQty = process.GetProperty("requiredQty").GetInt32(); var response = await client.PatchAsJsonAsync( $"/api/nodes/{process.GetProperty("id").GetString()}/progress", new { completedQty = requiredQty - 1, expectedVersion = process.GetProperty("version").GetString(), status = "done", riskLevel = "normal", }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task UpdateProgress_RequiresTheClientsExpectedVersion() { using var client = factory.CreateClient(); var process = await GetFirstProcessAsync(client); var response = await client.PatchAsJsonAsync( $"/api/nodes/{process.GetProperty("id").GetString()}/progress", new { completedQty = process.GetProperty("completedQty").GetInt32(), status = process.GetProperty("status").GetString(), riskLevel = process.GetProperty("riskLevel").GetString(), }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } private static int CountNodes(JsonElement node) { var count = 1; if (node.TryGetProperty("children", out var children)) { count += children.EnumerateArray().Sum(CountNodes); } return count; } private static int CountNodes(JsonElement node, string nodeType) { var count = node.GetProperty("nodeType").GetString() == nodeType ? 1 : 0; if (node.TryGetProperty("children", out var children)) { count += children.EnumerateArray().Sum(child => CountNodes(child, nodeType)); } return count; } private static async Task GetFirstProcessAsync(HttpClient client) { var orders = await client.GetFromJsonAsync("/api/orders?page=1&pageSize=20"); var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString(); var root = await client.GetFromJsonAsync($"/api/orders/{orderId}/tree"); return FindFirstProcess(root); } private static JsonElement FindFirstProcess(JsonElement node) { if (node.GetProperty("nodeType").GetString() == "process") { return node; } foreach (var child in node.GetProperty("children").EnumerateArray()) { var match = FindFirstProcess(child); if (match.ValueKind != JsonValueKind.Undefined) { return match; } } return default; } } public sealed class DashboardApiFactory : WebApplicationFactory { private readonly string databaseName = $"dashboard-tests-{Guid.NewGuid():N}"; public RecordingDashboardUpdateQueue UpdateQueue { get; } = new(); protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["Database:AutoMigrate"] = "true", ["Database:Seed"] = "true", }); }); builder.ConfigureServices(services => { services.RemoveAll>(); services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); services.RemoveAll(); services.AddSingleton(UpdateQueue); }); } } public sealed class RecordingDashboardUpdateQueue : IDashboardUpdateQueue { public int EnqueueCount { get; private set; } public ValueTask EnqueueAsync( UpdateNodeProgressResultDto update, CancellationToken cancellationToken) { EnqueueCount++; return ValueTask.CompletedTask; } public void Reset() { EnqueueCount = 0; } }