From bfe090ca10b0e4ad25334f728c020fa7ffa02221 Mon Sep 17 00:00:00 2001 From: Jason Sylvestre Date: Wed, 2 Sep 2026 15:40:07 -0700 Subject: [PATCH] refactor(financial): consolidate financial API to single endpoint Removed dedicated bulk, full-details, and validation endpoints to simplify the financial API surface. The single `/api/v1/financial/details/{value}` endpoint now serves as the primary interface, providing flattened financial details with integrated validation status. This streamlines client integrations and reduces maintenance overhead. --- README.md | 17 +- docs/deployment.md | 2 +- scripts/smoke-local.sh | 2 +- .../Financial/FinancialFunction.cs | 220 --------- src/Koi.Functions/Koi.Functions.csproj | 2 +- .../HttpFunctionAuthorizationPolicyTests.cs | 30 -- .../Financial/FinancialFunctionTests.cs | 465 +----------------- .../ResponseContractTests.cs | 34 +- 8 files changed, 52 insertions(+), 720 deletions(-) diff --git a/README.md b/README.md index 5f2096b..a2a039a 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,13 @@ Build, including financial chart details from Aggie Enterprise. | Method | Route | Authentication | Response | | --- | --- | --- | --- | -| `GET` | `/api/health` | None | `{"status":"healthy","service":"KOI","version":"0.1.2","revision":""}` | +| `GET` | `/api/health` | None | `{"status":"healthy","service":"KOI","version":"0.1.3","revision":""}` | | `GET` | `/api/v1/hello` | Bearer token | `{"message":"Hello from KOI"}` | | `GET` | `/api/v1/financial/details/{value}` | Bearer token | Flattened financial details for one chart string | -| `POST` | `/api/v1/financial/details` | Bearer token | Flattened financial details for an array of chart strings | -| `GET` | `/api/v1/financial/full-details/{value}` | Bearer token | Full financial details for one chart string | -| `POST` | `/api/v1/financial/full-details` | Bearer token | Full financial details for an array of chart strings | -| `GET` | `/api/v1/financial/validate/{value}` | Bearer token | Validation result for one chart string | -| `POST` | `/api/v1/financial/validate` | Bearer token | Validation results for an array of chart strings | - -The `details` endpoints return a flattened subset of the Aggie Enterprise data, -while `full-details` preserves the complete response. Financial responses include -`chartType`; flattened details include `validationStatus`, while full details and -validation results include the same display-ready text as `message` for Kuali -Build. Chart types are returned as `GL`, `PPM`, or `INVALID`. + +The financial details endpoint returns a flattened subset of the Aggie Enterprise +data. Its response includes `chartType` and the display-ready `validationStatus` +for Kuali Build. Chart types are returned as `GL`, `PPM`, or `INVALID`. Valid GL and PPM results return `This is a valid GL chart string.` and `This is a valid PPM chart string.`, respectively. Invalid results return `This is not a valid chart string.` diff --git a/docs/deployment.md b/docs/deployment.md index f93937f..a4e8ea1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -91,7 +91,7 @@ The script never sends a plaintext KOI bearer token to GitHub or Azure. ## Aggie Enterprise Financial configuration -The Financial endpoints require six Aggie Enterprise settings. Add the values +The Financial endpoint requires six Aggie Enterprise settings. Add the values for one environment to its local handoff file: ```dotenv diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh index 6ca9270..c586f70 100755 --- a/scripts/smoke-local.sh +++ b/scripts/smoke-local.sh @@ -46,7 +46,7 @@ health_status="$(request "$health_file" "$base_url/api/health")" assert_response \ "Anonymous health" \ 200 \ - '{"status":"healthy","service":"KOI","version":"0.1.2","revision":"local"}' \ + '{"status":"healthy","service":"KOI","version":"0.1.3","revision":"local"}' \ "$health_status" \ "$health_file" diff --git a/src/Koi.Functions/Financial/FinancialFunction.cs b/src/Koi.Functions/Financial/FinancialFunction.cs index 5ea33a7..a17c971 100644 --- a/src/Koi.Functions/Financial/FinancialFunction.cs +++ b/src/Koi.Functions/Financial/FinancialFunction.cs @@ -1,8 +1,6 @@ using System.Net; -using System.Text.Json; using Koi.Functions.Financial.Models; using Koi.Functions.Financial.Services; -using Koi.Functions.Http; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -10,9 +8,6 @@ namespace Koi.Functions.Financial; public sealed class FinancialFunction { - internal const int MaxBatchSize = 50; - internal const int MaxConcurrency = 5; - private readonly IAggieEnterpriseService _aggieEnterpriseService; public FinancialFunction(IAggieEnterpriseService aggieEnterpriseService) @@ -21,11 +16,6 @@ public FinancialFunction(IAggieEnterpriseService aggieEnterpriseService) } public const string FunctionName = "Financial"; - public const string BulkFunctionName = "FinancialBulk"; - public const string FullDetailsFunctionName = "FinancialFullDetails"; - public const string BulkFullDetailsFunctionName = "FinancialFullDetailsBulk"; - public const string ValidationFunctionName = "FinancialValidation"; - public const string BulkValidationFunctionName = "FinancialValidationBulk"; [Function(FunctionName)] public async Task Run( @@ -40,214 +30,4 @@ public async Task Run( await response.WriteAsJsonAsync(financialDetails, cancellationToken); return response; } - - [Function(FullDetailsFunctionName)] - public async Task RunFullDetails( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v1/financial/full-details/{value}")] HttpRequestData request, - string value, - CancellationToken cancellationToken) - { - var aeDetails = await _aggieEnterpriseService.GetAeDetailsAsync(value, cancellationToken); - var response = request.CreateResponse(); - response.StatusCode = HttpStatusCode.OK; - await response.WriteAsJsonAsync(aeDetails, cancellationToken); - return response; - } - - [Function(BulkFunctionName)] - public async Task RunBulk( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "v1/financial/details")] HttpRequestData request, - CancellationToken cancellationToken) - { - string[]? chartStrings; - try - { - chartStrings = await JsonSerializer.DeserializeAsync( - request.Body, - cancellationToken: cancellationToken); - } - catch (JsonException) - { - return await CreateInvalidBodyResponseAsync(request, cancellationToken); - } - - if (chartStrings is null) - { - return await CreateInvalidBodyResponseAsync(request, cancellationToken); - } - - if (chartStrings.Length > MaxBatchSize) - { - return await CreateBadRequestResponseAsync( - request, - $"request body must contain no more than {MaxBatchSize} chart strings", - cancellationToken); - } - - var aeDetails = await GetAeDetailsAsync(chartStrings, cancellationToken); - var financialDetails = aeDetails.Select(FinancialDetails.FromAeDetails).ToArray(); - - var response = request.CreateResponse(); - response.StatusCode = HttpStatusCode.OK; - await response.WriteAsJsonAsync(financialDetails, cancellationToken); - return response; - } - - [Function(BulkFullDetailsFunctionName)] - public async Task RunBulkFullDetails( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "v1/financial/full-details")] HttpRequestData request, - CancellationToken cancellationToken) - { - string[]? chartStrings; - try - { - chartStrings = await JsonSerializer.DeserializeAsync( - request.Body, - cancellationToken: cancellationToken); - } - catch (JsonException) - { - return await CreateInvalidBodyResponseAsync(request, cancellationToken); - } - - if (chartStrings is null) - { - return await CreateInvalidBodyResponseAsync(request, cancellationToken); - } - - if (chartStrings.Length > MaxBatchSize) - { - return await CreateBadRequestResponseAsync( - request, - $"request body must contain no more than {MaxBatchSize} chart strings", - cancellationToken); - } - - var aeDetails = await GetAeDetailsAsync(chartStrings, cancellationToken); - - var response = request.CreateResponse(); - response.StatusCode = HttpStatusCode.OK; - await response.WriteAsJsonAsync(aeDetails, cancellationToken); - return response; - } - - [Function(ValidationFunctionName)] - public async Task RunValidation( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v1/financial/validate/{value}")] HttpRequestData request, - string value, - CancellationToken cancellationToken) - { - var result = await _aggieEnterpriseService.ValidateAsync(value, cancellationToken); - var response = request.CreateResponse(); - response.StatusCode = HttpStatusCode.OK; - await response.WriteAsJsonAsync(result, cancellationToken); - return response; - } - - [Function(BulkValidationFunctionName)] - public async Task RunBulkValidation( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "v1/financial/validate")] HttpRequestData request, - CancellationToken cancellationToken) - { - string[]? chartStrings; - try - { - chartStrings = await JsonSerializer.DeserializeAsync( - request.Body, - cancellationToken: cancellationToken); - } - catch (JsonException) - { - return await CreateInvalidBodyResponseAsync(request, cancellationToken); - } - - if (chartStrings is null) - { - return await CreateInvalidBodyResponseAsync(request, cancellationToken); - } - - if (chartStrings.Length > MaxBatchSize) - { - return await CreateBadRequestResponseAsync( - request, - $"request body must contain no more than {MaxBatchSize} chart strings", - cancellationToken); - } - - var validationResults = await ValidateAsync(chartStrings, cancellationToken); - - var response = request.CreateResponse(); - response.StatusCode = HttpStatusCode.OK; - await response.WriteAsJsonAsync(validationResults, cancellationToken); - return response; - } - - private static async Task CreateInvalidBodyResponseAsync( - HttpRequestData request, - CancellationToken cancellationToken) - { - return await CreateBadRequestResponseAsync( - request, - "request body must be a JSON array of chart strings", - cancellationToken); - } - - private async Task GetAeDetailsAsync( - string[] chartStrings, - CancellationToken cancellationToken) - { - var results = new AeDetails[chartStrings.Length]; - - await Parallel.ForEachAsync( - Enumerable.Range(0, chartStrings.Length), - new ParallelOptions - { - CancellationToken = cancellationToken, - MaxDegreeOfParallelism = MaxConcurrency - }, - async (index, iterationCancellationToken) => - { - results[index] = await _aggieEnterpriseService.GetAeDetailsAsync( - chartStrings[index], - iterationCancellationToken); - }); - - return results; - } - - private async Task ValidateAsync( - string[] chartStrings, - CancellationToken cancellationToken) - { - var results = new FinancialValidationResult[chartStrings.Length]; - - await Parallel.ForEachAsync( - Enumerable.Range(0, chartStrings.Length), - new ParallelOptions - { - CancellationToken = cancellationToken, - MaxDegreeOfParallelism = MaxConcurrency - }, - async (index, iterationCancellationToken) => - { - results[index] = await _aggieEnterpriseService.ValidateAsync( - chartStrings[index], - iterationCancellationToken); - }); - - return results; - } - - private static async Task CreateBadRequestResponseAsync( - HttpRequestData request, - string error, - CancellationToken cancellationToken) - { - var response = request.CreateResponse(); - response.StatusCode = HttpStatusCode.BadRequest; - await response.WriteAsJsonAsync( - new ErrorResponse(error), - cancellationToken); - return response; - } } diff --git a/src/Koi.Functions/Koi.Functions.csproj b/src/Koi.Functions/Koi.Functions.csproj index 09acd9f..6fd159d 100644 --- a/src/Koi.Functions/Koi.Functions.csproj +++ b/src/Koi.Functions/Koi.Functions.csproj @@ -7,7 +7,7 @@ enable enable Koi.Functions - 0.1.2 + 0.1.3 diff --git a/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs b/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs index 853bb5e..b529c4e 100644 --- a/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs +++ b/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs @@ -27,36 +27,6 @@ public void FinancialIsAuthenticatedByDefault() Assert.False(_policy.IsAnonymous(FinancialFunction.FunctionName)); } - [Fact] - public void FinancialBulkIsAuthenticatedByDefault() - { - Assert.False(_policy.IsAnonymous(FinancialFunction.BulkFunctionName)); - } - - [Fact] - public void FinancialFullDetailsIsAuthenticatedByDefault() - { - Assert.False(_policy.IsAnonymous(FinancialFunction.FullDetailsFunctionName)); - } - - [Fact] - public void FinancialBulkFullDetailsIsAuthenticatedByDefault() - { - Assert.False(_policy.IsAnonymous(FinancialFunction.BulkFullDetailsFunctionName)); - } - - [Fact] - public void FinancialValidationIsAuthenticatedByDefault() - { - Assert.False(_policy.IsAnonymous(FinancialFunction.ValidationFunctionName)); - } - - [Fact] - public void FinancialValidationBulkIsAuthenticatedByDefault() - { - Assert.False(_policy.IsAnonymous(FinancialFunction.BulkValidationFunctionName)); - } - [Fact] public void UnknownFutureFunctionIsAuthenticatedByDefault() { diff --git a/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs b/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs index 560da81..c23b722 100644 --- a/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs +++ b/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.Globalization; using System.Net; using System.Reflection; using System.Security.Claims; @@ -10,114 +8,39 @@ using Koi.Functions.Financial; using Koi.Functions.Financial.Models; using Koi.Functions.Financial.Services; -using Koi.Functions.Http; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Xunit.Abstractions; namespace Koi.Functions.Tests.Financial; -public sealed class FinancialFunctionTests(ITestOutputHelper output) +public sealed class FinancialFunctionTests { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = false }; - [Theory] - [InlineData( - nameof(FinancialFunction.Run), - FinancialFunction.FunctionName, - "get", - "v1/financial/details/{value}")] - [InlineData( - nameof(FinancialFunction.RunBulk), - FinancialFunction.BulkFunctionName, - "post", - "v1/financial/details")] - [InlineData( - nameof(FinancialFunction.RunFullDetails), - FinancialFunction.FullDetailsFunctionName, - "get", - "v1/financial/full-details/{value}")] - [InlineData( - nameof(FinancialFunction.RunBulkFullDetails), - FinancialFunction.BulkFullDetailsFunctionName, - "post", - "v1/financial/full-details")] - [InlineData( - nameof(FinancialFunction.RunValidation), - FinancialFunction.ValidationFunctionName, - "get", - "v1/financial/validate/{value}")] - [InlineData( - nameof(FinancialFunction.RunBulkValidation), - FinancialFunction.BulkValidationFunctionName, - "post", - "v1/financial/validate")] - public void FinancialRoutesAreStable( - string methodName, - string expectedFunctionName, - string expectedHttpMethod, - string expectedRoute) + [Fact] + public void GetFinancialDetailsIsTheOnlyFinancialFunction() { - var method = typeof(FinancialFunction).GetMethod(methodName); - Assert.NotNull(method); + var method = Assert.Single( + typeof(FinancialFunction) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + method => method.IsDefined(typeof(FunctionAttribute), inherit: false)); + Assert.Equal(nameof(FinancialFunction.Run), method.Name); var function = method.GetCustomAttribute(); - Assert.Equal(expectedFunctionName, function?.Name); + Assert.Equal(FinancialFunction.FunctionName, function?.Name); var requestParameter = method.GetParameters() .Single(parameter => parameter.ParameterType == typeof(HttpRequestData)); var trigger = requestParameter.GetCustomAttribute(); Assert.NotNull(trigger); - Assert.Equal(expectedRoute, trigger.Route); - var methods = trigger.Methods ?? []; - Assert.Contains( - methods, - method => string.Equals(method, expectedHttpMethod, StringComparison.OrdinalIgnoreCase)); - } - - [Theory] - [InlineData(true, FinancialChartStringType.Gl, "GL", "This is a valid GL chart string.")] - [InlineData(true, FinancialChartStringType.Ppm, "PPM", "This is a valid PPM chart string.")] - [InlineData(false, FinancialChartStringType.Gl, "GL", "This is not a valid chart string.")] - [InlineData(false, FinancialChartStringType.Ppm, "PPM", "This is not a valid chart string.")] - [InlineData(false, FinancialChartStringType.Invalid, "INVALID", "This is not a valid chart string.")] - public async Task RunFullDetailsSerializesMessageForKualiBuild( - bool isValid, - FinancialChartStringType chartStringType, - string chartType, - string expectedMessage) - { - const string chartString = "example-chart-string"; - var service = new StubAggieEnterpriseService(new AeDetails - { - IsValid = isValid, - ChartType = chartType, - ChartString = chartString, - ChartStringType = chartStringType - }); - var function = new FinancialFunction(service); - var request = TestHttpRequestData.Create(string.Empty); - - var response = await function.RunFullDetails( - request, - chartString, - CancellationToken.None); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - response.Body.Position = 0; - using var document = await JsonDocument.ParseAsync(response.Body); - var root = document.RootElement; - Assert.Equal(isValid, root.GetProperty("isValid").GetBoolean()); - Assert.Equal(chartType, root.GetProperty("chartType").GetString()); - Assert.Equal(expectedMessage, root.GetProperty("message").GetString()); - Assert.Equal(chartString, root.GetProperty("chartString").GetString()); - Assert.Equal((int)chartStringType, root.GetProperty("chartStringType").GetInt32()); - output.WriteLine(root.GetRawText()); + Assert.Equal("v1/financial/details/{value}", trigger.Route); + var httpMethod = Assert.Single(trigger.Methods ?? []); + Assert.Equal("get", httpMethod, ignoreCase: true); } [Fact] @@ -151,8 +74,8 @@ public async Task RunReturnsOnlyFlatMappedFinancialDetails() [ new Approver { - FullName = "Full-details approver", - Email = "full-details@example.test" + FullName = "Unmapped approver", + Email = "unmapped@example.test" } ], PpmDetails = new PpmDetails @@ -303,7 +226,7 @@ public async Task RunReturnsOnlyFlatMappedFinancialDetails() } [Fact] - public async Task RunPassesCancellationTokenToService() + public async Task RunPassesValueAndCancellationTokenToService() { var service = new TrackingAggieEnterpriseService(); var function = new FinancialFunction(service); @@ -312,364 +235,30 @@ public async Task RunPassesCancellationTokenToService() await function.Run(request, "chart", cancellationTokenSource.Token); - Assert.Equal([cancellationTokenSource.Token], service.CancellationTokens); - } - - [Fact] - public async Task RunValidationPassesValueAndCancellationTokenToService() - { - var service = new TrackingAggieEnterpriseService(); - var function = new FinancialFunction(service); - var request = TestHttpRequestData.Create(string.Empty); - using var cancellationTokenSource = new CancellationTokenSource(); - - var response = await function.RunValidation( - request, - "chart", - cancellationTokenSource.Token); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(["chart"], service.ValidatedChartStrings); - Assert.Equal([cancellationTokenSource.Token], service.ValidationCancellationTokens); - } - - [Theory] - [InlineData( - true, - "0000-00000-0000000-000000-00-000-0000000000-000000-0000-000000-000000", - "GL", - "This is a valid GL chart string.")] - [InlineData( - true, - "0000000000-000000-0000000-000000", - "PPM", - "This is a valid PPM chart string.")] - [InlineData(false, "invalid", "INVALID", "This is not a valid chart string.")] - public async Task RunValidationSerializesMessageForKualiBuild( - bool isValid, - string chartString, - string expectedChartType, - string expectedMessage) - { - var service = new TrackingAggieEnterpriseService( - validationResultFactory: value => new FinancialValidationResult - { - ChartString = value, - IsValid = isValid - }); - var function = new FinancialFunction(service); - var request = TestHttpRequestData.Create(string.Empty); - - var response = await function.RunValidation( - request, - chartString, - CancellationToken.None); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - response.Body.Position = 0; - using var document = await JsonDocument.ParseAsync(response.Body); - var root = document.RootElement; - Assert.Equal(chartString, root.GetProperty("chartString").GetString()); - Assert.Equal(expectedChartType, root.GetProperty("chartType").GetString()); - Assert.Equal(expectedMessage, root.GetProperty("message").GetString()); - } - - [Fact] - public async Task RunBulkReturnsBadRequestWhenBatchExceedsMaximum() - { - var service = new TrackingAggieEnterpriseService(); - var function = new FinancialFunction(service); - var chartStrings = Enumerable.Range(0, FinancialFunction.MaxBatchSize + 1) - .Select(index => $"chart-{index}") - .ToArray(); - var request = TestHttpRequestData.Create(JsonSerializer.Serialize(chartStrings)); - - var response = await function.RunBulk(request, CancellationToken.None); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(0, service.CallCount); - - response.Body.Position = 0; - var error = await JsonSerializer.DeserializeAsync( - response.Body, - JsonOptions, - cancellationToken: CancellationToken.None); - Assert.Equal( - $"request body must contain no more than {FinancialFunction.MaxBatchSize} chart strings", - error?.Error); - } - - [Fact] - public async Task RunBulkProcessesMaximumBatchInInputOrderWithBoundedConcurrency() - { - var service = new TrackingAggieEnterpriseService(delayCalls: true); - var function = new FinancialFunction(service); - var chartStrings = Enumerable.Range(0, FinancialFunction.MaxBatchSize) - .Select(index => $"chart-{index}") - .ToArray(); - var request = TestHttpRequestData.Create(JsonSerializer.Serialize(chartStrings)); - using var cancellationTokenSource = new CancellationTokenSource(); - - var response = await function.RunBulk(request, cancellationTokenSource.Token); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - response.Body.Position = 0; - var results = await JsonSerializer.DeserializeAsync( - response.Body, - JsonOptions); - Assert.NotNull(results); - Assert.Equal(chartStrings, results.Select(result => result.ChartString)); - Assert.NotEqual(chartStrings, service.CompletionOrder); - Assert.Equal(FinancialFunction.MaxBatchSize, service.CallCount); - Assert.InRange(service.MaxConcurrentCalls, 2, FinancialFunction.MaxConcurrency); - Assert.All( - service.CancellationTokens, - cancellationToken => Assert.True(cancellationToken.CanBeCanceled)); - } - - [Fact] - public async Task RunBulkFullDetailsPreservesOriginalResponseGraph() - { - var chartStrings = new[] { "chart-one", "chart-two" }; - var service = new TrackingAggieEnterpriseService( - detailsFactory: chartString => new AeDetails - { - ChartString = chartString, - ChartStringType = FinancialChartStringType.Ppm, - SegmentDetails = - [ - new SegmentDetails - { - Entity = "GL Financial Department", - Name = $"Department for {chartString}" - } - ], - PpmDetails = new PpmDetails - { - ProjectTypeName = $"Project type for {chartString}" - } - }); - var function = new FinancialFunction(service); - var request = TestHttpRequestData.Create(JsonSerializer.Serialize(chartStrings)); - - var response = await function.RunBulkFullDetails(request, CancellationToken.None); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - response.Body.Position = 0; - var results = await JsonSerializer.DeserializeAsync( - response.Body, - JsonOptions); - Assert.NotNull(results); - Assert.Collection( - results, - result => - { - Assert.Equal("chart-one", result.ChartString); - Assert.Equal(FinancialChartStringType.Ppm, result.ChartStringType); - Assert.Equal("Department for chart-one", Assert.Single(result.SegmentDetails).Name); - Assert.Equal("Project type for chart-one", result.PpmDetails?.ProjectTypeName); - }, - result => - { - Assert.Equal("chart-two", result.ChartString); - Assert.Equal(FinancialChartStringType.Ppm, result.ChartStringType); - Assert.Equal("Department for chart-two", Assert.Single(result.SegmentDetails).Name); - Assert.Equal("Project type for chart-two", result.PpmDetails?.ProjectTypeName); - }); + Assert.Equal("chart", service.SegmentString); + Assert.Equal(cancellationTokenSource.Token, service.CancellationToken); } - [Fact] - public async Task RunBulkValidationProcessesInputInOrder() + private sealed class TrackingAggieEnterpriseService : IAggieEnterpriseService { - var service = new TrackingAggieEnterpriseService(delayCalls: true); - var function = new FinancialFunction(service); - var chartStrings = Enumerable.Range(0, 10) - .Select(index => $"chart-{index}") - .ToArray(); - var request = TestHttpRequestData.Create(JsonSerializer.Serialize(chartStrings)); - - var response = await function.RunBulkValidation(request, CancellationToken.None); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - response.Body.Position = 0; - var results = await JsonSerializer.DeserializeAsync( - response.Body, - JsonOptions); - Assert.NotNull(results); - Assert.Equal(chartStrings, results.Select(result => result.ChartString)); - Assert.Equal(chartStrings.Length, service.ValidationCallCount); - Assert.InRange(service.MaxConcurrentValidationCalls, 2, FinancialFunction.MaxConcurrency); - } - - [Fact] - public async Task RunBulkValidationSerializesMessagesForKualiBuild() - { - const string glChartString = - "0000-00000-0000000-000000-00-000-0000000000-000000-0000-000000-000000"; - const string ppmChartString = "0000000000-000000-0000000-000000"; - const string invalidChartString = "invalid"; - var chartStrings = new[] { glChartString, ppmChartString, invalidChartString }; - var service = new TrackingAggieEnterpriseService( - validationResultFactory: value => new FinancialValidationResult - { - ChartString = value, - IsValid = value != invalidChartString - }); - var function = new FinancialFunction(service); - var request = TestHttpRequestData.Create(JsonSerializer.Serialize(chartStrings)); - - var response = await function.RunBulkValidation(request, CancellationToken.None); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - response.Body.Position = 0; - using var document = await JsonDocument.ParseAsync(response.Body); - var results = document.RootElement.EnumerateArray().ToArray(); - Assert.Equal(chartStrings, results.Select(result => - result.GetProperty("chartString").GetString())); - Assert.Collection( - results, - result => - { - Assert.Equal("GL", result.GetProperty("chartType").GetString()); - Assert.Equal( - "This is a valid GL chart string.", - result.GetProperty("message").GetString()); - }, - result => - { - Assert.Equal("PPM", result.GetProperty("chartType").GetString()); - Assert.Equal( - "This is a valid PPM chart string.", - result.GetProperty("message").GetString()); - }, - result => - { - Assert.Equal("INVALID", result.GetProperty("chartType").GetString()); - Assert.Equal( - "This is not a valid chart string.", - result.GetProperty("message").GetString()); - }); - } - - private sealed class TrackingAggieEnterpriseService( - bool delayCalls = false, - Func? detailsFactory = null, - Func? validationResultFactory = null) - : IAggieEnterpriseService - { - private int _activeCalls; - private int _callCount; - private int _maxConcurrentCalls; - private int _activeValidationCalls; - private int _validationCallCount; - private int _maxConcurrentValidationCalls; - private readonly ConcurrentQueue _cancellationTokens = []; - private readonly ConcurrentQueue _validationCancellationTokens = []; - private readonly ConcurrentQueue _validatedChartStrings = []; - private readonly ConcurrentQueue _completionOrder = []; - - public int CallCount => _callCount; + public string SegmentString { get; private set; } = string.Empty; - public int MaxConcurrentCalls => _maxConcurrentCalls; + public CancellationToken CancellationToken { get; private set; } - public int ValidationCallCount => _validationCallCount; - - public int MaxConcurrentValidationCalls => _maxConcurrentValidationCalls; - - public CancellationToken[] CancellationTokens => _cancellationTokens.ToArray(); - - public CancellationToken[] ValidationCancellationTokens => - _validationCancellationTokens.ToArray(); - - public string[] ValidatedChartStrings => _validatedChartStrings.ToArray(); - - public string[] CompletionOrder => _completionOrder.ToArray(); - - public async Task GetAeDetailsAsync( + public Task GetAeDetailsAsync( string segmentString, CancellationToken cancellationToken) { - Interlocked.Increment(ref _callCount); - _cancellationTokens.Enqueue(cancellationToken); - var activeCalls = Interlocked.Increment(ref _activeCalls); - UpdateMaxConcurrentCalls(activeCalls); - - try - { - if (delayCalls) - { - var index = int.Parse( - segmentString.AsSpan("chart-".Length), - CultureInfo.InvariantCulture); - var delayMultiplier = FinancialFunction.MaxConcurrency - - (index % FinancialFunction.MaxConcurrency); - await Task.Delay(delayMultiplier * 5, cancellationToken); - } - - _completionOrder.Enqueue(segmentString); - return detailsFactory?.Invoke(segmentString) - ?? new AeDetails { ChartString = segmentString }; - } - finally - { - Interlocked.Decrement(ref _activeCalls); - } + SegmentString = segmentString; + CancellationToken = cancellationToken; + return Task.FromResult(new AeDetails { ChartString = segmentString }); } - public async Task ValidateAsync( + public Task ValidateAsync( string segmentString, CancellationToken cancellationToken) { - Interlocked.Increment(ref _validationCallCount); - _validationCancellationTokens.Enqueue(cancellationToken); - _validatedChartStrings.Enqueue(segmentString); - var activeCalls = Interlocked.Increment(ref _activeValidationCalls); - UpdateMaximum(ref _maxConcurrentValidationCalls, activeCalls); - - try - { - if (delayCalls) - { - var index = int.Parse( - segmentString.AsSpan("chart-".Length), - CultureInfo.InvariantCulture); - var delayMultiplier = FinancialFunction.MaxConcurrency - - (index % FinancialFunction.MaxConcurrency); - await Task.Delay(delayMultiplier * 5, cancellationToken); - } - - return validationResultFactory?.Invoke(segmentString) - ?? new FinancialValidationResult { ChartString = segmentString }; - } - finally - { - Interlocked.Decrement(ref _activeValidationCalls); - } - } - - private void UpdateMaxConcurrentCalls(int activeCalls) - { - UpdateMaximum(ref _maxConcurrentCalls, activeCalls); - } - - private static void UpdateMaximum(ref int maximum, int activeCalls) - { - var currentMaximum = maximum; - while (activeCalls > currentMaximum) - { - var observedMaximum = Interlocked.CompareExchange( - ref maximum, - activeCalls, - currentMaximum); - if (observedMaximum == currentMaximum) - { - return; - } - - currentMaximum = observedMaximum; - } + throw new NotSupportedException(); } } @@ -712,7 +301,7 @@ private TestHttpRequestData(TestFunctionContext context, Stream body) public override IEnumerable Identities { get; } = []; - public override string Method => "POST"; + public override string Method => "GET"; public static TestHttpRequestData Create(string body) { diff --git a/tests/Koi.Functions.Tests/ResponseContractTests.cs b/tests/Koi.Functions.Tests/ResponseContractTests.cs index 4ebf532..2605da4 100644 --- a/tests/Koi.Functions.Tests/ResponseContractTests.cs +++ b/tests/Koi.Functions.Tests/ResponseContractTests.cs @@ -18,7 +18,7 @@ public void HealthContractIsStable() Assert.Equal("healthy", response.Status); Assert.Equal("KOI", response.Service); - Assert.Equal("0.1.2", response.Version); + Assert.Equal("0.1.3", response.Version); Assert.NotEmpty(response.Revision); } @@ -31,7 +31,7 @@ public void HelloContractIsStable() } [Fact] - public void FinancialFullDetailsContractIsStable() + public void AeDetailsContractIsStable() { var response = new AeDetails { @@ -86,29 +86,29 @@ public void FinancialDetailsMapsMissingOptionalValuesToEmptyStrings() [InlineData( true, FinancialChartStringType.Gl, - "0000-00000-0000000-000000-00-000-0000000000-000000-0000-000000-000000")] + "This is a valid GL chart string.")] [InlineData( true, FinancialChartStringType.Ppm, - "0000000000-000000-0000000-000000")] - [InlineData(false, FinancialChartStringType.Invalid, "invalid")] - public void FinancialDetailsValidationStatusMatchesValidationMessage( + "This is a valid PPM chart string.")] + [InlineData( + false, + FinancialChartStringType.Invalid, + "This is not a valid chart string.")] + public void FinancialDetailsValidationStatusMatchesAeDetailsMessage( bool isValid, FinancialChartStringType chartStringType, - string chartString) + string expectedMessage) { - var details = FinancialDetails.FromAeDetails(new AeDetails + var aeDetails = new AeDetails { IsValid = isValid, ChartStringType = chartStringType - }); - var validation = new FinancialValidationResult - { - IsValid = isValid, - ChartString = chartString }; + var details = FinancialDetails.FromAeDetails(aeDetails); - Assert.Equal(validation.Message, details.ValidationStatus); + Assert.Equal(expectedMessage, aeDetails.Message); + Assert.Equal(aeDetails.Message, details.ValidationStatus); } [Theory] @@ -117,7 +117,7 @@ public void FinancialDetailsValidationStatusMatchesValidationMessage( [InlineData(false, FinancialChartStringType.Gl, "This is not a valid chart string.")] [InlineData(false, FinancialChartStringType.Ppm, "This is not a valid chart string.")] [InlineData(false, FinancialChartStringType.Invalid, "This is not a valid chart string.")] - public void FinancialFullDetailsMessageMatchesValidationResult( + public void AeDetailsMessageMatchesValidationStatus( bool isValid, FinancialChartStringType chartStringType, string expectedMessage) @@ -132,7 +132,7 @@ public void FinancialFullDetailsMessageMatchesValidationResult( } [Fact] - public void FinancialValidationContractIsStable() + public void FinancialValidationResultContractIsStable() { const string chartString = "0000-00000-0000000-000000-00-000-0000000000-000000-0000-000000-000000"; @@ -174,7 +174,7 @@ public void FinancialValidationContractIsStable() "PPM", "This is not a valid chart string.")] [InlineData(false, "invalid", "INVALID", "This is not a valid chart string.")] - public void FinancialValidationMessageMatchesValidationResult( + public void FinancialValidationResultDerivesChartTypeAndMessage( bool isValid, string chartString, string expectedChartType,