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
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ permissions:
env:
PROJECT_PATH: 'ZWebAPI/ZWebAPI.csproj'
PACKAGE_OUTPUT_DIRECTORY: ${{ github.workspace }}/output
DOTNET_VERSION: 9.0.x
# Every target framework the library ships, so "dotnet test" can run the suite
# on each of them. The newest SDK installed is the one that drives the build.
DOTNET_VERSION: |
8.0.x
9.0.x
10.0.x

# Cancel superseded PR runs, but never cancel a release in progress;
# queueing runs on main also prevents two merges from racing the same version.
Expand Down
26 changes: 26 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project>

<!--
Target frameworks shared by the library and its test project, so the two can
never drift onto different runtimes.
-->
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
</PropertyGroup>

<!--
EF Core floor for each target framework. These are minimums: NuGet resolves a
floor to the lowest satisfying version, so this is what consumers actually get
unless they reference something newer.

net8.0 floors at 8.0.11 rather than 8.0.0 because EF Core 8.0.0 pulls
Microsoft.Extensions.Caching.Memory 8.0.0, which carries a high severity
advisory (NU1903, GHSA-qj66-m88j-hmgj).
-->
<PropertyGroup>
<EFCoreVersion Condition="'$(TargetFramework)' == 'net8.0'">8.0.11</EFCoreVersion>
<EFCoreVersion Condition="'$(TargetFramework)' == 'net9.0'">9.0.0</EFCoreVersion>
<EFCoreVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.0</EFCoreVersion>
</PropertyGroup>

</Project>
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,20 @@
# ZWebAPI
ZWebAPI is a library that brings additional functionalities for web API projects.

## Supported frameworks

ZWebAPI ships assets for .NET 8, .NET 9 and .NET 10.

| Target framework | Notes |
| --- | --- |
| `net10.0` | LTS, supported until November 2028 |
| `net9.0` | STS, out of support since May 2026 |
| `net8.0` | LTS, out of support after November 2026 |

ZWebAPI depends on [ZDatabase](https://github.com/RicardoZambon/ZDatabase) and
[ZSecurity](https://github.com/RicardoZambon/ZSecurity), which ship the same three targets.
ZDatabase carries the EF Core dependency for each of them.

The `net9.0` and `net8.0` targets exist for compatibility with applications that have not yet
moved to .NET 10. Both of those runtimes are at or near end of support, so new projects should
target `net10.0`.
203 changes: 203 additions & 0 deletions ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
using ZWebAPI.Exporters;
using ZWebAPI.UnitTests.Fakes.ExportersFake;

namespace ZWebAPI.UnitTests.Exporters
{
/// <summary>
/// Unit tests for <see cref="ZWebAPI.Exporters.ColumnMetadataResolver"/>.
/// </summary>
public class ColumnMetadataResolverTests
{
/// <summary>
/// Test the Resolve should throw when the row type is null.
/// </summary>
[Fact]
public void Resolve_Fail_ThrowsWhenRowTypeIsNull()
{
// Arrange
Type? rowType = null;

// Act
Action act = () => ColumnMetadataResolver.Resolve(rowType!);

// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("rowType");
}

/// <summary>
/// Test the Resolve should use the property name when the attribute is absent.
/// </summary>
[Fact]
public void Resolve_Pass_UsesPropertyNameWhenAttributeIsAbsent()
{
// Arrange
Type rowType = typeof(ExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Should().Contain(x => x.Header == nameof(ExportRowFake.ID));
}

/// <summary>
/// Test the Resolve should use the header declared on the attribute.
/// </summary>
[Fact]
public void Resolve_Pass_UsesCustomHeaderFromAttribute()
{
// Arrange
Type rowType = typeof(ExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Should().Contain(x => x.Header == "Full name");
columns.Should().NotContain(x => x.Header == nameof(ExportRowFake.Name));
}

/// <summary>
/// Test the Resolve should fall back to the property name when the attribute header is blank.
/// </summary>
[Fact]
public void Resolve_Pass_FallsBackToPropertyNameWhenHeaderIsBlank()
{
// Arrange
Type rowType = typeof(BlankHeaderExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Should().ContainSingle();
columns[0].Header.Should().Be(nameof(BlankHeaderExportRowFake.Blank));
}

/// <summary>
/// Test the Resolve should skip properties flagged as ignored.
/// </summary>
[Fact]
public void Resolve_Pass_SkipsIgnoredProperties()
{
// Arrange
Type rowType = typeof(ExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Should().NotContain(x => x.Header == nameof(ExportRowFake.Secret));
columns.Should().HaveCount(7);
}

/// <summary>
/// Test the Resolve should skip write-only properties.
/// </summary>
[Fact]
public void Resolve_Pass_SkipsWriteOnlyProperties()
{
// Arrange
Type rowType = typeof(WriteOnlyExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Should().ContainSingle();
columns[0].Header.Should().Be(nameof(WriteOnlyExportRowFake.Readable));
}

/// <summary>
/// Test the Resolve should order explicitly ordered columns first and keep declaration order otherwise.
/// </summary>
[Fact]
public void Resolve_Pass_OrdersByExplicitOrderThenDeclaration()
{
// Arrange
Type rowType = typeof(OrderedExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Select(x => x.Header).Should().ContainInOrder(
nameof(OrderedExportRowFake.First),
nameof(OrderedExportRowFake.Second),
nameof(OrderedExportRowFake.Third),
nameof(OrderedExportRowFake.Fourth));
}

/// <summary>
/// Test the Resolve should return an empty list for a type without exportable properties.
/// </summary>
[Fact]
public void Resolve_Pass_ReturnsEmptyForTypeWithoutProperties()
{
// Arrange
Type rowType = typeof(EmptyExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Should().BeEmpty();
}

/// <summary>
/// Test the Resolve should default the column type to Text and honor the declared type otherwise.
/// </summary>
[Fact]
public void Resolve_Pass_UsesDeclaredColumnTypeOrDefaultsToText()
{
// Arrange
Type rowType = typeof(ExportRowFake);

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(rowType);

// Assert
columns.Single(x => x.Header == nameof(ExportRowFake.ID)).Type.Should().Be(ExportColumnType.Text);
columns.Single(x => x.Header == nameof(ExportRowFake.Amount)).Type.Should().Be(ExportColumnType.Number);
columns.Single(x => x.Header == nameof(ExportRowFake.Price)).Type.Should().Be(ExportColumnType.Currency);
columns.Single(x => x.Header == nameof(ExportRowFake.CreatedOn)).Type.Should().Be(ExportColumnType.Date);
columns.Single(x => x.Header == nameof(ExportRowFake.ChangedOn)).Type.Should().Be(ExportColumnType.DateTime);
columns.Single(x => x.Header == nameof(ExportRowFake.IsActive)).Type.Should().Be(ExportColumnType.Boolean);
}

/// <summary>
/// Test the Resolve should build selectors that read the value from the row instance.
/// </summary>
[Fact]
public void Resolve_Pass_BuildsSelectorReadingTheRowValue()
{
// Arrange
ExportRowFake row = new() { ID = 7, Name = "Row name" };

// Act
IReadOnlyList<ExportColumn> columns = ColumnMetadataResolver.Resolve(typeof(ExportRowFake));

// Assert
columns.Single(x => x.Header == nameof(ExportRowFake.ID)).ValueSelector(row).Should().Be(7L);
columns.Single(x => x.Header == "Full name").ValueSelector(row).Should().Be("Row name");
}

/// <summary>
/// Test the Resolve should serve the cached metadata on subsequent calls for the same type.
/// </summary>
[Fact]
public void Resolve_Pass_ReturnsCachedResultForTheSameType()
{
// Arrange
Type rowType = typeof(ExportRowFake);

// Act
IReadOnlyList<ExportColumn> first = ColumnMetadataResolver.Resolve(rowType);
IReadOnlyList<ExportColumn> second = ColumnMetadataResolver.Resolve(rowType);

// Assert
second.Should().BeSameAs(first);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using ZWebAPI.Exporters;
using ZWebAPI.Exporters.Exceptions;

namespace ZWebAPI.UnitTests.Exporters.Exceptions
{
/// <summary>
/// Unit tests for <see cref="ZWebAPI.Exporters.Exceptions.ExportRowLimitExceededException"/>.
/// </summary>
public class ExportRowLimitExceededExceptionTests
{
/// <summary>
/// Test the constructor should expose the configured limit and describe it in the message.
/// </summary>
[Fact]
public void Constructor_Pass_ExposesTheConfiguredLimit()
{
// Arrange
int maxRows = 1234;

// Act
ExportRowLimitExceededException exception = new(maxRows);

// Assert
exception.MaxRows.Should().Be(maxRows);
exception.Message.Should().Be($"The export result exceeds the configured limit of {maxRows} rows.");
}

/// <summary>
/// Test the exception should carry the shared export row limit when raised by the library.
/// </summary>
[Fact]
public void Constructor_Pass_CarriesTheSharedRowLimit()
{
// Arrange & Act
ExportRowLimitExceededException exception = new(ExportConstants.MaxExportRows);

// Assert
ExportConstants.MaxExportRows.Should().Be(50_000);
exception.MaxRows.Should().Be(50_000);
exception.Should().BeAssignableTo<Exception>();
}
}
}
67 changes: 67 additions & 0 deletions ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using ZWebAPI.Exporters;

namespace ZWebAPI.UnitTests.Exporters
{
/// <summary>
/// Unit tests for <see cref="ZWebAPI.Exporters.ExportColumnAttribute"/>.
/// </summary>
public class ExportColumnAttributeTests
{
/// <summary>
/// Test the parameterless constructor should leave every override at its default.
/// </summary>
[Fact]
public void Constructor_Pass_LeavesDefaultsWhenNoArgumentIsProvided()
{
// Arrange & Act
ExportColumnAttribute attribute = new();

// Assert
attribute.Header.Should().BeNull();
attribute.Ignore.Should().BeFalse();
attribute.Order.Should().Be(int.MaxValue);
attribute.Type.Should().Be(ExportColumnType.Text);
}

/// <summary>
/// Test the header constructor should store the provided header.
/// </summary>
[Fact]
public void Constructor_Pass_StoresTheProvidedHeader()
{
// Arrange
string header = "Custom";

// Act
ExportColumnAttribute attribute = new(header);

// Assert
attribute.Header.Should().Be(header);
attribute.Ignore.Should().BeFalse();
attribute.Order.Should().Be(int.MaxValue);
attribute.Type.Should().Be(ExportColumnType.Text);
}

/// <summary>
/// Test every override should be settable through the object initializer.
/// </summary>
[Fact]
public void Properties_Pass_AcceptEveryOverride()
{
// Arrange & Act
ExportColumnAttribute attribute = new()
{
Header = "Header",
Ignore = true,
Order = 3,
Type = ExportColumnType.Date,
};

// Assert
attribute.Header.Should().Be("Header");
attribute.Ignore.Should().BeTrue();
attribute.Order.Should().Be(3);
attribute.Type.Should().Be(ExportColumnType.Date);
}
}
}
Loading
Loading