Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions build/Test-PowerShellModule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/ACMP/Models/InferredObjectJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
12 changes: 12 additions & 0 deletions src/ACMP/Models/ReportCell.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
1 change: 1 addition & 0 deletions src/ACMP/Models/ReportResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace ACMP.Models
{
[JsonConverter(typeof(ReportResultJsonConverter))]
public class ReportResult
{
[JsonPropertyName("Rows")]
Expand Down
187 changes: 187 additions & 0 deletions src/ACMP/Models/ReportResultJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -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<ReportResult>
{
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<ReportColumn> ReadColumns(JsonElement root, JsonSerializerOptions options)
{
if (!root.TryGetProperty("Columns", out var columnsElement))
{
return new List<ReportColumn>();
}

if (columnsElement.ValueKind != JsonValueKind.Array)
{
throw new JsonException($"Report result 'Columns' must be a JSON array, but found '{columnsElement.ValueKind}'.");
}

return JsonSerializer.Deserialize<List<ReportColumn>>(columnsElement.GetRawText(), options)
?? new List<ReportColumn>();
}

private static Dictionary<int, ReportColumn> IndexColumns(List<ReportColumn> columns)
{
var columnsByIndex = new Dictionary<int, ReportColumn>();
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<ReportRow> ReadRows(
JsonElement root,
List<ReportColumn> columns,
Dictionary<int, ReportColumn> columnsByIndex)
{
if (!root.TryGetProperty("Rows", out var rowsElement))
{
return new List<ReportRow>();
}

if (rowsElement.ValueKind != JsonValueKind.Array)
{
throw new JsonException($"Report result 'Rows' must be a JSON array, but found '{rowsElement.ValueKind}'.");
}

var rows = new List<ReportRow>();
foreach (var rowElement in rowsElement.EnumerateArray())
{
rows.Add(ReadRow(rowElement, columns, columnsByIndex));
}

return rows;
}

private static ReportRow ReadRow(
JsonElement rowElement,
List<ReportColumn> columns,
Dictionary<int, ReportColumn> 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);
}
}
}
3 changes: 1 addition & 2 deletions src/ACMP/Models/ReportRow.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace ACMP.Models
{
public class ReportRow
{
public List<ReportCell> Cells { get; set; } = new List<ReportCell>();
}
}
99 changes: 99 additions & 0 deletions tests/ACMP.Tests/AcmpClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,105 @@ await client.PostAsync<object>(
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<List<ReportRow>>(result.Rows);
Assert.Equal(2, rows.Count);

var columns = Assert.IsType<List<ReportColumn>>(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<object?[]>(cells[4].Value);
Assert.Equal("CSP", tags[0]);
Assert.Equal(2m, tags[1]);

var metadata = Assert.IsType<Dictionary<string, object?>>(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<ReportResult>(seatChangesJson, AcmpClient.JsonSerializerOptions);
var currentSeats = JsonSerializer.Deserialize<ReportResult>(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<JsonException>(
() => JsonSerializer.Deserialize<ReportResult>(json, AcmpClient.JsonSerializerOptions));

Assert.Contains(expectedMessage, exception.Message);
}

[Fact]
public void FieldValuesDeserializeToClrTypes()
{
Expand Down