diff --git a/build/Test-PowerShellModule.ps1 b/build/Test-PowerShellModule.ps1 index 5982080..ce311b1 100644 --- a/build/Test-PowerShellModule.ps1 +++ b/build/Test-PowerShellModule.ps1 @@ -206,6 +206,20 @@ try { -Name 'Imported cmdlets' ` -Expected $declaredCmdlets ` -Actual @($exportedCommands.Name) + + $reportColumn = [ACMP.Models.ReportColumn]::new() + $reportColumn.CellIndex = 0 + $reportColumn.Name = 'Quantity' + $reportColumn.Type = 'Decimal' + $reportCell = [ACMP.Models.ReportCell]::new() + $reportCell.Column = $reportColumn + $reportCell.Value = [decimal]11 + $reportRow = [ACMP.Models.ReportRow]::new() + $reportRow.Cells.Add($reportCell) + if ($reportRow.Cells[0].Column.Name -cne 'Quantity' -or + $reportRow.Cells[0].Value -ne 11) { + throw 'Report rows must pair each value with its report column metadata.' + } } finally { Remove-Module -Name ACMP -Force -ErrorAction SilentlyContinue diff --git a/src/ACMP/Models/InferredObjectJsonConverter.cs b/src/ACMP/Models/InferredObjectJsonConverter.cs index 14eb3bc..d276bf9 100644 --- a/src/ACMP/Models/InferredObjectJsonConverter.cs +++ b/src/ACMP/Models/InferredObjectJsonConverter.cs @@ -26,7 +26,7 @@ public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOp JsonSerializer.Serialize(writer, value, value.GetType(), options); } - private static object? ConvertElement(JsonElement element) + internal static object? ConvertElement(JsonElement element) { switch (element.ValueKind) { diff --git a/src/ACMP/Models/ReportCell.cs b/src/ACMP/Models/ReportCell.cs new file mode 100644 index 0000000..0f7a097 --- /dev/null +++ b/src/ACMP/Models/ReportCell.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace ACMP.Models +{ + public class ReportCell + { + public ReportColumn Column { get; set; } = default!; + + [JsonConverter(typeof(InferredObjectJsonConverter))] + public object? Value { get; set; } + } +} diff --git a/src/ACMP/Models/ReportResult.cs b/src/ACMP/Models/ReportResult.cs index 1c5fb9b..f30d3c3 100644 --- a/src/ACMP/Models/ReportResult.cs +++ b/src/ACMP/Models/ReportResult.cs @@ -4,6 +4,7 @@ namespace ACMP.Models { + [JsonConverter(typeof(ReportResultJsonConverter))] public class ReportResult { [JsonPropertyName("Rows")] diff --git a/src/ACMP/Models/ReportResultJsonConverter.cs b/src/ACMP/Models/ReportResultJsonConverter.cs new file mode 100644 index 0000000..6ed8337 --- /dev/null +++ b/src/ACMP/Models/ReportResultJsonConverter.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ACMP.Models +{ + public sealed class ReportResultJsonConverter : JsonConverter + { + public override bool HandleNull => true; + + public override ReportResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using (var document = JsonDocument.ParseValue(ref reader)) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"Report results must be JSON objects, but found '{root.ValueKind}'."); + } + + var columns = ReadColumns(root, options); + var columnsByIndex = IndexColumns(columns); + var rows = ReadRows(root, columns, columnsByIndex); + + return new ReportResult + { + Columns = columns, + Rows = rows + }; + } + } + + public override void Write(Utf8JsonWriter writer, ReportResult value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartObject(); + writer.WritePropertyName("Rows"); + writer.WriteStartArray(); + if (value.Rows is not null) + { + foreach (var row in value.Rows) + { + writer.WriteStartObject(); + writer.WritePropertyName("Cells"); + writer.WriteStartArray(); + foreach (var cell in row.Cells) + { + WriteCellValue(writer, cell.Value, options); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + } + + writer.WriteEndArray(); + writer.WritePropertyName("Columns"); + JsonSerializer.Serialize(writer, value.Columns, options); + writer.WriteEndObject(); + } + + private static List ReadColumns(JsonElement root, JsonSerializerOptions options) + { + if (!root.TryGetProperty("Columns", out var columnsElement)) + { + return new List(); + } + + if (columnsElement.ValueKind != JsonValueKind.Array) + { + throw new JsonException($"Report result 'Columns' must be a JSON array, but found '{columnsElement.ValueKind}'."); + } + + return JsonSerializer.Deserialize>(columnsElement.GetRawText(), options) + ?? new List(); + } + + private static Dictionary IndexColumns(List columns) + { + var columnsByIndex = new Dictionary(); + foreach (var column in columns) + { + if (!column.CellIndex.HasValue || + column.CellIndex.Value < 0 || + column.CellIndex.Value > int.MaxValue) + { + throw new JsonException( + $"Report column '{column.Name}' has invalid CellIndex '{column.CellIndex}'."); + } + + var index = (int)column.CellIndex.Value; + if (columnsByIndex.ContainsKey(index)) + { + throw new JsonException($"Report columns contain duplicate CellIndex '{index}'."); + } + + columnsByIndex[index] = column; + } + + return columnsByIndex; + } + + private static List ReadRows( + JsonElement root, + List columns, + Dictionary columnsByIndex) + { + if (!root.TryGetProperty("Rows", out var rowsElement)) + { + return new List(); + } + + if (rowsElement.ValueKind != JsonValueKind.Array) + { + throw new JsonException($"Report result 'Rows' must be a JSON array, but found '{rowsElement.ValueKind}'."); + } + + var rows = new List(); + foreach (var rowElement in rowsElement.EnumerateArray()) + { + rows.Add(ReadRow(rowElement, columns, columnsByIndex)); + } + + return rows; + } + + private static ReportRow ReadRow( + JsonElement rowElement, + List columns, + Dictionary columnsByIndex) + { + if (rowElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException($"Report rows must be JSON objects, but found '{rowElement.ValueKind}'."); + } + + if (!rowElement.TryGetProperty("Cells", out var cellsElement) || + cellsElement.ValueKind != JsonValueKind.Array) + { + throw new JsonException("Report rows must contain a JSON array named 'Cells'."); + } + + var cellCount = cellsElement.GetArrayLength(); + if (cellCount != columns.Count) + { + throw new JsonException( + $"Report row contains {cellCount} cells, but the result defines {columns.Count} columns."); + } + + var row = new ReportRow(); + var index = 0; + foreach (var cellElement in cellsElement.EnumerateArray()) + { + if (!columnsByIndex.TryGetValue(index, out var column)) + { + throw new JsonException($"Report result does not define a column for cell index '{index}'."); + } + + row.Cells.Add(new ReportCell + { + Column = column, + Value = InferredObjectJsonConverter.ConvertElement(cellElement) + }); + index++; + } + + return row; + } + + private static void WriteCellValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + } +} diff --git a/src/ACMP/Models/ReportRow.cs b/src/ACMP/Models/ReportRow.cs index d5e37dd..2562305 100644 --- a/src/ACMP/Models/ReportRow.cs +++ b/src/ACMP/Models/ReportRow.cs @@ -1,10 +1,9 @@ -using System; using System.Collections.Generic; -using System.Text.Json.Serialization; namespace ACMP.Models { public class ReportRow { + public List Cells { get; set; } = new List(); } } diff --git a/tests/ACMP.Tests/AcmpClientTests.cs b/tests/ACMP.Tests/AcmpClientTests.cs index aedcf55..bb63031 100644 --- a/tests/ACMP.Tests/AcmpClientTests.cs +++ b/tests/ACMP.Tests/AcmpClientTests.cs @@ -156,6 +156,105 @@ await client.PostAsync( Assert.DoesNotContain("ParentAccountId", companiesRequest.Body); } + [Fact] + public async Task ExecuteReportPairsCellsWithTypedColumnsAndClrValues() + { + const string reportJson = "{" + + "\"Rows\":[" + + "{\"Cells\":[\"2026-01-01\",10,true,null,[\"CSP\",2],{\"source\":\"history\"}]}," + + "{\"Cells\":[\"2026-02-01\",11,false,\"changed\",[],{}]}" + + "]," + + "\"Columns\":[" + + "{\"CellIndex\":0,\"Name\":\"EffectiveDate\",\"Type\":\"DateTime\"}," + + "{\"CellIndex\":1,\"Name\":\"Quantity\",\"Type\":\"Decimal\"}," + + "{\"CellIndex\":2,\"Name\":\"Active\",\"Type\":\"Boolean\"}," + + "{\"CellIndex\":3,\"Name\":\"Comment\",\"Type\":\"String\"}," + + "{\"CellIndex\":4,\"Name\":\"Tags\",\"Type\":\"Object\"}," + + "{\"CellIndex\":5,\"Name\":\"Metadata\",\"Type\":\"Object\"}" + + "]" + + "}"; + var cancellationToken = TestContext.Current.CancellationToken; + var handler = new RecordingHandler( + request => request.RequestUri.AbsolutePath == "/GetSessionToken" + ? JsonResponse("\"session-token\"") + : JsonResponse(reportJson)); + + ReportResult? result; + using (var client = new AcmpClient(new Uri("https://marketplace.example.com"), handler)) + { + await client.ConnectAsync("user@example.com", "secret", cancellationToken); + result = await client.ExecuteReportAsync( + new ExecuteReportRequest + { + ReportName = "MSCSPSeatChanges", + Parameters = new ExecuteReportRequestParameters() + }, + cancellationToken); + } + + Assert.NotNull(result); + var rows = Assert.IsType>(result.Rows); + Assert.Equal(2, rows.Count); + + var columns = Assert.IsType>(result.Columns); + var cells = rows[0].Cells; + Assert.Equal(6, cells.Count); + Assert.Same(columns[0], cells[0].Column); + Assert.Equal("EffectiveDate", cells[0].Column.Name); + Assert.Equal("DateTime", cells[0].Column.Type); + Assert.Equal("2026-01-01", cells[0].Value); + Assert.Equal("Quantity", cells[1].Column.Name); + Assert.Equal(10m, cells[1].Value); + Assert.Equal(true, cells[2].Value); + Assert.Null(cells[3].Value); + + var tags = Assert.IsType(cells[4].Value); + Assert.Equal("CSP", tags[0]); + Assert.Equal(2m, tags[1]); + + var metadata = Assert.IsType>(cells[5].Value); + Assert.Equal("history", metadata["source"]); + Assert.Equal(11m, rows[1].Cells[1].Value); + Assert.DoesNotContain(cells, cell => cell.Value is JsonElement); + } + + [Fact] + public void ReportRowsSupportDifferentReportSchemas() + { + const string seatChangesJson = + "{\"Rows\":[{\"Cells\":[\"2026-01-01\",10]}]," + + "\"Columns\":[" + + "{\"CellIndex\":0,\"Name\":\"EffectiveDate\",\"Type\":\"DateTime\"}," + + "{\"CellIndex\":1,\"Name\":\"Quantity\",\"Type\":\"Decimal\"}]}"; + const string currentSeatsJson = + "{\"Rows\":[{\"Cells\":[\"tenant-1\",\"Business Standard\"]}]," + + "\"Columns\":[" + + "{\"CellIndex\":0,\"Name\":\"TenantId\",\"Type\":\"String\"}," + + "{\"CellIndex\":1,\"Name\":\"ProductName\",\"Type\":\"String\"}]}"; + + var seatChanges = JsonSerializer.Deserialize(seatChangesJson, AcmpClient.JsonSerializerOptions); + var currentSeats = JsonSerializer.Deserialize(currentSeatsJson, AcmpClient.JsonSerializerOptions); + + Assert.Equal("Quantity", seatChanges!.Rows![0].Cells[1].Column.Name); + Assert.Equal(10m, seatChanges.Rows[0].Cells[1].Value); + Assert.Equal("TenantId", currentSeats!.Rows![0].Cells[0].Column.Name); + Assert.Equal("tenant-1", currentSeats.Rows[0].Cells[0].Value); + Assert.Equal("ProductName", currentSeats.Rows[0].Cells[1].Column.Name); + Assert.Equal("Business Standard", currentSeats.Rows[0].Cells[1].Value); + } + + [Theory] + [InlineData("{\"Rows\":[[\"2026-01-01\"]],\"Columns\":[]}", "Report rows must be JSON objects")] + [InlineData("{\"Rows\":[{}],\"Columns\":[]}", "must contain a JSON array named 'Cells'")] + [InlineData("{\"Rows\":[{\"Cells\":[1]}],\"Columns\":[]}", "contains 1 cells")] + public void ReportRowsRejectMalformedShapes(string json, string expectedMessage) + { + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json, AcmpClient.JsonSerializerOptions)); + + Assert.Contains(expectedMessage, exception.Message); + } + [Fact] public void FieldValuesDeserializeToClrTypes() {