From 809acca511e3454033e90767f8cc576cc802fd67 Mon Sep 17 00:00:00 2001 From: Jason Sylvestre Date: Tue, 1 Sep 2026 09:26:33 -0700 Subject: [PATCH 1/2] feat(financial): introduce flattened and full detail financial endpoints The existing `/api/v1/financial/details` endpoints now provide a flattened view of Aggie Enterprise data, optimized for simpler consumption, such as in Kuali Build. This involves mapping complex nested structures to top-level properties and concatenating errors and warnings into single string fields. New `/api/v1/financial/full-details` endpoints are introduced to return the complete, original Aggie Enterprise financial response graph, preserving all structural details. --- README.md | 16 +- scripts/smoke-authenticated.sh | 4 +- scripts/smoke-local.sh | 2 +- .../Financial/FinancialFunction.cs | 55 +++ .../Financial/Models/FinancialDetails.cs | 96 +++++ src/Koi.Functions/Koi.Functions.csproj | 2 +- .../HttpFunctionAuthorizationPolicyTests.cs | 12 + .../Financial/FinancialFunctionTests.cs | 333 ++++++++++++++++-- .../ResponseContractTests.cs | 39 +- 9 files changed, 523 insertions(+), 36 deletions(-) create mode 100644 src/Koi.Functions/Financial/Models/FinancialDetails.cs diff --git a/README.md b/README.md index 92fe309..350dd71 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,20 @@ Build, including financial chart details from Aggie Enterprise. | Method | Route | Authentication | Response | | --- | --- | --- | --- | -| `GET` | `/api/health` | None | `{"status":"healthy","service":"KOI","version":"0.1.1","revision":""}` | +| `GET` | `/api/health` | None | `{"status":"healthy","service":"KOI","version":"0.1.2","revision":""}` | | `GET` | `/api/v1/hello` | Bearer token | `{"message":"Hello from KOI"}` | -| `GET` | `/api/v1/financial/details/{value}` | Bearer token | Financial details for one chart string | -| `POST` | `/api/v1/financial/details` | Bearer token | Financial details for an array of chart strings | +| `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 | -Financial details and validation results include `chartType` and `message` for -direct display in Kuali Build. Chart types are returned as `GL`, `PPM`, or -`INVALID`. Valid GL and PPM results return +The `details` endpoints return a flattened subset of the Aggie Enterprise data, +while `full-details` preserves the complete response. Financial responses include +`chartType`; full details and validation results also include `message` for direct +display in 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/scripts/smoke-authenticated.sh b/scripts/smoke-authenticated.sh index 3dcf065..38171bb 100755 --- a/scripts/smoke-authenticated.sh +++ b/scripts/smoke-authenticated.sh @@ -92,10 +92,10 @@ fi if ! jq --exit-status \ --arg chart_string "$financial_chart_string" \ - '.chartString == $chart_string and .isValid == true and (.errors | length == 0)' \ + '.chartString == $chart_string and .isValid == true and .error == ""' \ "$financial_body" >/dev/null; then echo "Financial integration returned 200 without a valid Aggie Enterprise response." >&2 - jq '{chartString, chartStringType, isValid, error, warning}' "$financial_body" >&2 + jq '{chartString, chartType, isValid, error, warning}' "$financial_body" >&2 exit 1 fi diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh index 92c96e3..6ca9270 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.1","revision":"local"}' \ + '{"status":"healthy","service":"KOI","version":"0.1.2","revision":"local"}' \ "$health_status" \ "$health_file" diff --git a/src/Koi.Functions/Financial/FinancialFunction.cs b/src/Koi.Functions/Financial/FinancialFunction.cs index 2398f02..5ea33a7 100644 --- a/src/Koi.Functions/Financial/FinancialFunction.cs +++ b/src/Koi.Functions/Financial/FinancialFunction.cs @@ -22,6 +22,8 @@ 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"; @@ -30,6 +32,20 @@ public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v1/financial/details/{value}")] HttpRequestData request, string value, CancellationToken cancellationToken) + { + var aeDetails = await _aggieEnterpriseService.GetAeDetailsAsync(value, cancellationToken); + var financialDetails = FinancialDetails.FromAeDetails(aeDetails); + var response = request.CreateResponse(); + response.StatusCode = HttpStatusCode.OK; + 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(); @@ -68,6 +84,45 @@ public async Task RunBulk( 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(); diff --git a/src/Koi.Functions/Financial/Models/FinancialDetails.cs b/src/Koi.Functions/Financial/Models/FinancialDetails.cs new file mode 100644 index 0000000..ebdd451 --- /dev/null +++ b/src/Koi.Functions/Financial/Models/FinancialDetails.cs @@ -0,0 +1,96 @@ +namespace Koi.Functions.Financial.Models; + +public sealed class FinancialDetails +{ + private const string GlFinancialDepartmentEntity = "GL Financial Department"; + private const string PrincipalInvestigatorRole = "Principal Investigator"; + private const string ProjectManagerRole = "Project Manager"; + private const string ProjectRoleType = "P"; + + public bool IsValid { get; set; } + + public string ChartType { get; set; } = string.Empty; + + public string ChartString { get; set; } = string.Empty; + + public string Error { get; set; } = string.Empty; + + public string Warning { get; set; } = string.Empty; + + public string GlFinancialDepartmentName { get; set; } = string.Empty; + + public string ProjectStartDate { get; set; } = string.Empty; + + public string ProjectCompletionDate { get; set; } = string.Empty; + + public string AwardStatus { get; set; } = string.Empty; + + public string AwardStartDate { get; set; } = string.Empty; + + public string AwardEndDate { get; set; } = string.Empty; + + public string AwardInfo { get; set; } = string.Empty; + + public string ProjectTypeName { get; set; } = string.Empty; + + public string PrincipalInvestigatorName { get; set; } = string.Empty; + + public string PrincipalInvestigatorEmail { get; set; } = string.Empty; + + public string ProjectManagerName { get; set; } = string.Empty; + + public string ProjectManagerEmail { get; set; } = string.Empty; + + public string FundPurpose { get; set; } = string.Empty; + + public static FinancialDetails FromAeDetails(AeDetails aeDetails) + { + ArgumentNullException.ThrowIfNull(aeDetails); + + var principalInvestigator = FindProjectRoleApprover( + aeDetails, + PrincipalInvestigatorRole); + var projectManager = FindProjectRoleApprover(aeDetails, ProjectManagerRole); + + return new FinancialDetails + { + IsValid = aeDetails.IsValid, + ChartType = aeDetails.ChartType, + ChartString = aeDetails.ChartString, + Error = aeDetails.Error, + Warning = aeDetails.Warning, + GlFinancialDepartmentName = aeDetails.SegmentDetails + .FirstOrDefault(segment => string.Equals( + segment.Entity, + GlFinancialDepartmentEntity, + StringComparison.Ordinal)) + ?.Name ?? string.Empty, + ProjectStartDate = aeDetails.PpmDetails?.ProjectStartDate ?? string.Empty, + ProjectCompletionDate = aeDetails.PpmDetails?.ProjectCompletionDate ?? string.Empty, + AwardStatus = aeDetails.PpmDetails?.AwardStatus ?? string.Empty, + AwardStartDate = aeDetails.PpmDetails?.AwardStartDate ?? string.Empty, + AwardEndDate = aeDetails.PpmDetails?.AwardEndDate ?? string.Empty, + AwardInfo = aeDetails.PpmDetails?.AwardInfo ?? string.Empty, + ProjectTypeName = aeDetails.PpmDetails?.ProjectTypeName ?? string.Empty, + PrincipalInvestigatorName = principalInvestigator?.Name ?? string.Empty, + PrincipalInvestigatorEmail = principalInvestigator?.Email ?? string.Empty, + ProjectManagerName = projectManager?.Name ?? string.Empty, + ProjectManagerEmail = projectManager?.Email ?? string.Empty, + FundPurpose = aeDetails.FundPurpose ?? string.Empty + }; + } + + private static Approver? FindProjectRoleApprover(AeDetails aeDetails, string roleName) + { + var role = aeDetails.PpmDetails?.Roles + .Where(role => string.Equals(role.Type, ProjectRoleType, StringComparison.Ordinal)) + .Where(role => string.Equals( + role.RoleName, + roleName, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(role => role.Order) + .FirstOrDefault(); + + return role?.Approvers.FirstOrDefault(); + } +} diff --git a/src/Koi.Functions/Koi.Functions.csproj b/src/Koi.Functions/Koi.Functions.csproj index 844dcc8..09acd9f 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.1 + 0.1.2 diff --git a/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs b/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs index 459a14e..853bb5e 100644 --- a/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs +++ b/tests/Koi.Functions.Tests/Authentication/HttpFunctionAuthorizationPolicyTests.cs @@ -33,6 +33,18 @@ 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() { diff --git a/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs b/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs index a5f5cf6..9fefaab 100644 --- a/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs +++ b/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs @@ -1,9 +1,10 @@ +using System.Collections.Concurrent; +using System.Globalization; using System.Net; +using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; -using System.Collections.Concurrent; -using System.Globalization; using AggieEnterpriseApi.Validation; using Azure.Core.Serialization; using Koi.Functions.Financial; @@ -20,13 +21,72 @@ namespace Koi.Functions.Tests.Financial; public sealed class FinancialFunctionTests(ITestOutputHelper output) { + 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) + { + var method = typeof(FinancialFunction).GetMethod(methodName); + Assert.NotNull(method); + + var function = method.GetCustomAttribute(); + Assert.Equal(expectedFunctionName, 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 RunSerializesMessageForKualiBuild( + public async Task RunFullDetailsSerializesMessageForKualiBuild( bool isValid, FinancialChartStringType chartStringType, string chartType, @@ -43,20 +103,189 @@ public async Task RunSerializesMessageForKualiBuild( var function = new FinancialFunction(service); var request = TestHttpRequestData.Create(string.Empty); - var response = await function.Run(request, chartString, CancellationToken.None); + 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()); + 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()); } + [Fact] + public async Task RunReturnsOnlyFlatMappedFinancialDetails() + { + const string chartString = "0000000000-000000-0000000-000000"; + var service = new StubAggieEnterpriseService(new AeDetails + { + IsValid = false, + ChartType = "PPM", + ChartString = chartString, + ChartStringType = FinancialChartStringType.Ppm, + Errors = ["First error.", "Second error."], + Warnings = ["First warning.", "Second warning."], + FundPurpose = "Research", + SegmentDetails = + [ + new SegmentDetails + { + Entity = "GL Financial Department", + Name = "Biological Sciences" + } + ], + Approvers = + [ + new Approver + { + FullName = "Full-details approver", + Email = "full-details@example.test" + } + ], + PpmDetails = new PpmDetails + { + ProjectStartDate = "2025-01-01", + ProjectCompletionDate = "2026-12-31", + AwardStatus = "Active", + AwardStartDate = "2025-02-01", + AwardEndDate = "2026-11-30", + AwardInfo = "AWARD-123", + ProjectTypeName = "Sponsored", + Roles = + [ + new PpmRoles + { + RoleName = "Principal Investigator", + Type = "A", + Approvers = + [ + new Approver + { + FullName = "Award Principal Investigator", + Email = "award-pi@example.test" + } + ] + }, + new PpmRoles + { + RoleName = "Principal Investigator", + Type = "P", + Approvers = + [ + new Approver + { + FirstName = "Ada", + LastName = "Lovelace", + Email = "ada@example.test" + }, + new Approver + { + FullName = "Second Project Principal Investigator", + Email = "second-pi@example.test" + } + ] + }, + new PpmRoles + { + RoleName = "Project Manager", + Type = "P", + Approvers = + [ + new Approver + { + FullName = "Grace Hopper", + Email = "grace@example.test" + } + ] + } + ] + } + }); + var function = new FinancialFunction(service); + var request = TestHttpRequestData.Create(string.Empty); + + var response = await function.Run(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; + + var expectedProperties = new[] + { + "isValid", + "chartType", + "chartString", + "error", + "warning", + "glFinancialDepartmentName", + "projectStartDate", + "projectCompletionDate", + "awardStatus", + "awardStartDate", + "awardEndDate", + "awardInfo", + "projectTypeName", + "principalInvestigatorName", + "principalInvestigatorEmail", + "projectManagerName", + "projectManagerEmail", + "fundPurpose" + }; + Assert.Equal( + expectedProperties.Order(StringComparer.Ordinal), + root.EnumerateObject().Select(property => property.Name).Order(StringComparer.Ordinal)); + + Assert.False(root.GetProperty("isValid").GetBoolean()); + Assert.Equal("PPM", root.GetProperty("chartType").GetString()); + Assert.Equal(chartString, root.GetProperty("chartString").GetString()); + Assert.Equal("First error. Second error.", root.GetProperty("error").GetString()); + Assert.Equal("First warning. Second warning.", root.GetProperty("warning").GetString()); + Assert.Equal( + "Biological Sciences", + root.GetProperty("glFinancialDepartmentName").GetString()); + Assert.Equal("2025-01-01", root.GetProperty("projectStartDate").GetString()); + Assert.Equal("2026-12-31", root.GetProperty("projectCompletionDate").GetString()); + Assert.Equal("Active", root.GetProperty("awardStatus").GetString()); + Assert.Equal("2025-02-01", root.GetProperty("awardStartDate").GetString()); + Assert.Equal("2026-11-30", root.GetProperty("awardEndDate").GetString()); + Assert.Equal("AWARD-123", root.GetProperty("awardInfo").GetString()); + Assert.Equal("Sponsored", root.GetProperty("projectTypeName").GetString()); + Assert.Equal( + "Lovelace, Ada", + root.GetProperty("principalInvestigatorName").GetString()); + Assert.Equal( + "ada@example.test", + root.GetProperty("principalInvestigatorEmail").GetString()); + Assert.Equal("Grace Hopper", root.GetProperty("projectManagerName").GetString()); + Assert.Equal( + "grace@example.test", + root.GetProperty("projectManagerEmail").GetString()); + Assert.Equal("Research", root.GetProperty("fundPurpose").GetString()); + + string[] fullDetailsOnlyProperties = + [ + "message", + "chartStringType", + "errors", + "warnings", + "segmentDetails", + "approvers", + "ppmDetails", + "hasWarnings" + ]; + Assert.All( + fullDetailsOnlyProperties, + propertyName => Assert.False(root.TryGetProperty(propertyName, out _))); + } + [Fact] public async Task RunPassesCancellationTokenToService() { @@ -124,9 +353,9 @@ public async Task RunValidationSerializesMessageForKualiBuild( 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()); + Assert.Equal(chartString, root.GetProperty("chartString").GetString()); + Assert.Equal(expectedChartType, root.GetProperty("chartType").GetString()); + Assert.Equal(expectedMessage, root.GetProperty("message").GetString()); } [Fact] @@ -147,6 +376,7 @@ public async Task RunBulkReturnsBadRequestWhenBatchExceedsMaximum() 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", @@ -169,7 +399,9 @@ public async Task RunBulkProcessesMaximumBatchInInputOrderWithBoundedConcurrency Assert.Equal(HttpStatusCode.OK, response.StatusCode); response.Body.Position = 0; - var results = await JsonSerializer.DeserializeAsync(response.Body); + var results = await JsonSerializer.DeserializeAsync( + response.Body, + JsonOptions); Assert.NotNull(results); Assert.Equal(chartStrings, results.Select(result => result.ChartString)); Assert.NotEqual(chartStrings, service.CompletionOrder); @@ -180,6 +412,57 @@ public async Task RunBulkProcessesMaximumBatchInInputOrderWithBoundedConcurrency 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); + }); + } + [Fact] public async Task RunBulkValidationProcessesInputInOrder() { @@ -195,7 +478,9 @@ public async Task RunBulkValidationProcessesInputInOrder() Assert.Equal(HttpStatusCode.OK, response.StatusCode); response.Body.Position = 0; - var results = await JsonSerializer.DeserializeAsync(response.Body); + 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); @@ -226,34 +511,35 @@ public async Task RunBulkValidationSerializesMessagesForKualiBuild() using var document = await JsonDocument.ParseAsync(response.Body); var results = document.RootElement.EnumerateArray().ToArray(); Assert.Equal(chartStrings, results.Select(result => - result.GetProperty("ChartString").GetString())); + result.GetProperty("chartString").GetString())); Assert.Collection( results, result => { - Assert.Equal("GL", result.GetProperty("ChartType").GetString()); + Assert.Equal("GL", result.GetProperty("chartType").GetString()); Assert.Equal( "This is a valid GL chart string.", - result.GetProperty("Message").GetString()); + result.GetProperty("message").GetString()); }, result => { - Assert.Equal("PPM", result.GetProperty("ChartType").GetString()); + Assert.Equal("PPM", result.GetProperty("chartType").GetString()); Assert.Equal( "This is a valid PPM chart string.", - result.GetProperty("Message").GetString()); + result.GetProperty("message").GetString()); }, result => { - Assert.Equal("INVALID", result.GetProperty("ChartType").GetString()); + Assert.Equal("INVALID", result.GetProperty("chartType").GetString()); Assert.Equal( "This is not a valid chart string.", - result.GetProperty("Message").GetString()); + result.GetProperty("message").GetString()); }); } private sealed class TrackingAggieEnterpriseService( bool delayCalls = false, + Func? detailsFactory = null, Func? validationResultFactory = null) : IAggieEnterpriseService { @@ -307,7 +593,8 @@ public async Task GetAeDetailsAsync( } _completionOrder.Enqueue(segmentString); - return new AeDetails { ChartString = segmentString }; + return detailsFactory?.Invoke(segmentString) + ?? new AeDetails { ChartString = segmentString }; } finally { @@ -417,7 +704,7 @@ public static TestHttpRequestData Create(string body) .AddOptions() .Configure(options => { - options.Serializer = new JsonObjectSerializer(); + options.Serializer = new JsonObjectSerializer(JsonOptions); }) .BuildServiceProvider(); diff --git a/tests/Koi.Functions.Tests/ResponseContractTests.cs b/tests/Koi.Functions.Tests/ResponseContractTests.cs index 7482fb9..42fd738 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.1", response.Version); + Assert.Equal("0.1.2", response.Version); Assert.NotEmpty(response.Revision); } @@ -31,7 +31,7 @@ public void HelloContractIsStable() } [Fact] - public void FinancialDetailsContractIsStable() + public void FinancialFullDetailsContractIsStable() { var response = new AeDetails { @@ -48,13 +48,46 @@ public void FinancialDetailsContractIsStable() Assert.Equal(FinancialChartStringType.Gl, response.ChartStringType); } + [Fact] + public void FinancialDetailsMapsMissingOptionalValuesToEmptyStrings() + { + var response = FinancialDetails.FromAeDetails(new AeDetails + { + IsValid = false, + ChartType = "INVALID", + ChartString = "invalid", + Errors = ["Invalid Chart Type"], + Warnings = ["Example warning"], + FundPurpose = null + }); + + Assert.False(response.IsValid); + Assert.Equal("INVALID", response.ChartType); + Assert.Equal("invalid", response.ChartString); + Assert.Equal("Invalid Chart Type", response.Error); + Assert.Equal("Example warning", response.Warning); + Assert.Equal(string.Empty, response.GlFinancialDepartmentName); + Assert.Equal(string.Empty, response.ProjectStartDate); + Assert.Equal(string.Empty, response.ProjectCompletionDate); + Assert.Equal(string.Empty, response.AwardStatus); + Assert.Equal(string.Empty, response.AwardStartDate); + Assert.Equal(string.Empty, response.AwardEndDate); + Assert.Equal(string.Empty, response.AwardInfo); + Assert.Equal(string.Empty, response.ProjectTypeName); + Assert.Equal(string.Empty, response.PrincipalInvestigatorName); + Assert.Equal(string.Empty, response.PrincipalInvestigatorEmail); + Assert.Equal(string.Empty, response.ProjectManagerName); + Assert.Equal(string.Empty, response.ProjectManagerEmail); + Assert.Equal(string.Empty, response.FundPurpose); + } + [Theory] [InlineData(true, FinancialChartStringType.Gl, "This is a valid GL chart string.")] [InlineData(true, FinancialChartStringType.Ppm, "This is a valid PPM chart string.")] [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 FinancialDetailsMessageMatchesValidationResult( + public void FinancialFullDetailsMessageMatchesValidationResult( bool isValid, FinancialChartStringType chartStringType, string expectedMessage) From b4e189bf166c4a4be1bce857683c0821e7d612c1 Mon Sep 17 00:00:00 2001 From: Jason Sylvestre Date: Tue, 1 Sep 2026 09:47:27 -0700 Subject: [PATCH 2/2] feat(financial): source award info from segment details Updates the FinancialDetails model to retrieve 'AwardInfo' from the Aggie Enterprise segment details rather than PPM details. This aligns 'AwardInfo' with other segment-based financial fields, ensuring consistent data sourcing. A new private helper method, 'FindSegmentName', is introduced to encapsulate the logic for looking up segment names by entity, reducing code duplication for fields like 'GlFinancialDepartmentName' and the updated 'AwardInfo'. --- .../Financial/Models/FinancialDetails.cs | 22 +++++++++++++------ .../Financial/FinancialFunctionTests.cs | 12 ++++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/Koi.Functions/Financial/Models/FinancialDetails.cs b/src/Koi.Functions/Financial/Models/FinancialDetails.cs index ebdd451..b41de0b 100644 --- a/src/Koi.Functions/Financial/Models/FinancialDetails.cs +++ b/src/Koi.Functions/Financial/Models/FinancialDetails.cs @@ -3,6 +3,7 @@ namespace Koi.Functions.Financial.Models; public sealed class FinancialDetails { private const string GlFinancialDepartmentEntity = "GL Financial Department"; + private const string AwardEntity = "Award"; private const string PrincipalInvestigatorRole = "Principal Investigator"; private const string ProjectManagerRole = "Project Manager"; private const string ProjectRoleType = "P"; @@ -59,18 +60,15 @@ public static FinancialDetails FromAeDetails(AeDetails aeDetails) ChartString = aeDetails.ChartString, Error = aeDetails.Error, Warning = aeDetails.Warning, - GlFinancialDepartmentName = aeDetails.SegmentDetails - .FirstOrDefault(segment => string.Equals( - segment.Entity, - GlFinancialDepartmentEntity, - StringComparison.Ordinal)) - ?.Name ?? string.Empty, + GlFinancialDepartmentName = FindSegmentName( + aeDetails, + GlFinancialDepartmentEntity), ProjectStartDate = aeDetails.PpmDetails?.ProjectStartDate ?? string.Empty, ProjectCompletionDate = aeDetails.PpmDetails?.ProjectCompletionDate ?? string.Empty, AwardStatus = aeDetails.PpmDetails?.AwardStatus ?? string.Empty, AwardStartDate = aeDetails.PpmDetails?.AwardStartDate ?? string.Empty, AwardEndDate = aeDetails.PpmDetails?.AwardEndDate ?? string.Empty, - AwardInfo = aeDetails.PpmDetails?.AwardInfo ?? string.Empty, + AwardInfo = FindSegmentName(aeDetails, AwardEntity), ProjectTypeName = aeDetails.PpmDetails?.ProjectTypeName ?? string.Empty, PrincipalInvestigatorName = principalInvestigator?.Name ?? string.Empty, PrincipalInvestigatorEmail = principalInvestigator?.Email ?? string.Empty, @@ -80,6 +78,16 @@ public static FinancialDetails FromAeDetails(AeDetails aeDetails) }; } + private static string FindSegmentName(AeDetails aeDetails, string entity) + { + return aeDetails.SegmentDetails + .FirstOrDefault(segment => string.Equals( + segment.Entity, + entity, + StringComparison.Ordinal)) + ?.Name ?? string.Empty; + } + private static Approver? FindProjectRoleApprover(AeDetails aeDetails, string roleName) { var role = aeDetails.PpmDetails?.Roles diff --git a/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs b/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs index 9fefaab..826f2ce 100644 --- a/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs +++ b/tests/Koi.Functions.Tests/Financial/FinancialFunctionTests.cs @@ -139,6 +139,12 @@ public async Task RunReturnsOnlyFlatMappedFinancialDetails() { Entity = "GL Financial Department", Name = "Biological Sciences" + }, + new SegmentDetails + { + Entity = "Award", + Code = "A232815", + Name = "A232815 SP0A232815 PO2610129 Electrochemically Mediated Air Separation Modules (EM-ASM)" } ], Approvers = @@ -156,7 +162,7 @@ public async Task RunReturnsOnlyFlatMappedFinancialDetails() AwardStatus = "Active", AwardStartDate = "2025-02-01", AwardEndDate = "2026-11-30", - AwardInfo = "AWARD-123", + AwardInfo = "legacy award number", ProjectTypeName = "Sponsored", Roles = [ @@ -256,7 +262,9 @@ public async Task RunReturnsOnlyFlatMappedFinancialDetails() Assert.Equal("Active", root.GetProperty("awardStatus").GetString()); Assert.Equal("2025-02-01", root.GetProperty("awardStartDate").GetString()); Assert.Equal("2026-11-30", root.GetProperty("awardEndDate").GetString()); - Assert.Equal("AWARD-123", root.GetProperty("awardInfo").GetString()); + Assert.Equal( + "A232815 SP0A232815 PO2610129 Electrochemically Mediated Air Separation Modules (EM-ASM)", + root.GetProperty("awardInfo").GetString()); Assert.Equal("Sponsored", root.GetProperty("projectTypeName").GetString()); Assert.Equal( "Lovelace, Ada",