diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fee26d9..374af53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c23d862 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,26 @@ + + + + + net8.0;net9.0;net10.0 + + + + + 8.0.11 + 9.0.0 + 10.0.0 + + + diff --git a/README.md b/README.md index f9af268..dadae3a 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs b/ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs new file mode 100644 index 0000000..6e7a4be --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs @@ -0,0 +1,203 @@ +using ZWebAPI.Exporters; +using ZWebAPI.UnitTests.Fakes.ExportersFake; + +namespace ZWebAPI.UnitTests.Exporters +{ + /// + /// Unit tests for . + /// + public class ColumnMetadataResolverTests + { + /// + /// Test the Resolve should throw when the row type is null. + /// + [Fact] + public void Resolve_Fail_ThrowsWhenRowTypeIsNull() + { + // Arrange + Type? rowType = null; + + // Act + Action act = () => ColumnMetadataResolver.Resolve(rowType!); + + // Assert + act.Should().Throw() + .WithParameterName("rowType"); + } + + /// + /// Test the Resolve should use the property name when the attribute is absent. + /// + [Fact] + public void Resolve_Pass_UsesPropertyNameWhenAttributeIsAbsent() + { + // Arrange + Type rowType = typeof(ExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Should().Contain(x => x.Header == nameof(ExportRowFake.ID)); + } + + /// + /// Test the Resolve should use the header declared on the attribute. + /// + [Fact] + public void Resolve_Pass_UsesCustomHeaderFromAttribute() + { + // Arrange + Type rowType = typeof(ExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Should().Contain(x => x.Header == "Full name"); + columns.Should().NotContain(x => x.Header == nameof(ExportRowFake.Name)); + } + + /// + /// Test the Resolve should fall back to the property name when the attribute header is blank. + /// + [Fact] + public void Resolve_Pass_FallsBackToPropertyNameWhenHeaderIsBlank() + { + // Arrange + Type rowType = typeof(BlankHeaderExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Should().ContainSingle(); + columns[0].Header.Should().Be(nameof(BlankHeaderExportRowFake.Blank)); + } + + /// + /// Test the Resolve should skip properties flagged as ignored. + /// + [Fact] + public void Resolve_Pass_SkipsIgnoredProperties() + { + // Arrange + Type rowType = typeof(ExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Should().NotContain(x => x.Header == nameof(ExportRowFake.Secret)); + columns.Should().HaveCount(7); + } + + /// + /// Test the Resolve should skip write-only properties. + /// + [Fact] + public void Resolve_Pass_SkipsWriteOnlyProperties() + { + // Arrange + Type rowType = typeof(WriteOnlyExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Should().ContainSingle(); + columns[0].Header.Should().Be(nameof(WriteOnlyExportRowFake.Readable)); + } + + /// + /// Test the Resolve should order explicitly ordered columns first and keep declaration order otherwise. + /// + [Fact] + public void Resolve_Pass_OrdersByExplicitOrderThenDeclaration() + { + // Arrange + Type rowType = typeof(OrderedExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Select(x => x.Header).Should().ContainInOrder( + nameof(OrderedExportRowFake.First), + nameof(OrderedExportRowFake.Second), + nameof(OrderedExportRowFake.Third), + nameof(OrderedExportRowFake.Fourth)); + } + + /// + /// Test the Resolve should return an empty list for a type without exportable properties. + /// + [Fact] + public void Resolve_Pass_ReturnsEmptyForTypeWithoutProperties() + { + // Arrange + Type rowType = typeof(EmptyExportRowFake); + + // Act + IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType); + + // Assert + columns.Should().BeEmpty(); + } + + /// + /// Test the Resolve should default the column type to Text and honor the declared type otherwise. + /// + [Fact] + public void Resolve_Pass_UsesDeclaredColumnTypeOrDefaultsToText() + { + // Arrange + Type rowType = typeof(ExportRowFake); + + // Act + IReadOnlyList 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); + } + + /// + /// Test the Resolve should build selectors that read the value from the row instance. + /// + [Fact] + public void Resolve_Pass_BuildsSelectorReadingTheRowValue() + { + // Arrange + ExportRowFake row = new() { ID = 7, Name = "Row name" }; + + // Act + IReadOnlyList 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"); + } + + /// + /// Test the Resolve should serve the cached metadata on subsequent calls for the same type. + /// + [Fact] + public void Resolve_Pass_ReturnsCachedResultForTheSameType() + { + // Arrange + Type rowType = typeof(ExportRowFake); + + // Act + IReadOnlyList first = ColumnMetadataResolver.Resolve(rowType); + IReadOnlyList second = ColumnMetadataResolver.Resolve(rowType); + + // Assert + second.Should().BeSameAs(first); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/Exceptions/ExportRowLimitExceededExceptionTests.cs b/ZWebAPI.UnitTests/Exporters/Exceptions/ExportRowLimitExceededExceptionTests.cs new file mode 100644 index 0000000..c51b4e7 --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/Exceptions/ExportRowLimitExceededExceptionTests.cs @@ -0,0 +1,43 @@ +using ZWebAPI.Exporters; +using ZWebAPI.Exporters.Exceptions; + +namespace ZWebAPI.UnitTests.Exporters.Exceptions +{ + /// + /// Unit tests for . + /// + public class ExportRowLimitExceededExceptionTests + { + /// + /// Test the constructor should expose the configured limit and describe it in the message. + /// + [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."); + } + + /// + /// Test the exception should carry the shared export row limit when raised by the library. + /// + [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(); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs b/ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs new file mode 100644 index 0000000..c45a4e7 --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs @@ -0,0 +1,67 @@ +using ZWebAPI.Exporters; + +namespace ZWebAPI.UnitTests.Exporters +{ + /// + /// Unit tests for . + /// + public class ExportColumnAttributeTests + { + /// + /// Test the parameterless constructor should leave every override at its default. + /// + [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); + } + + /// + /// Test the header constructor should store the provided header. + /// + [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); + } + + /// + /// Test every override should be settable through the object initializer. + /// + [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); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/ExportColumnTests.cs b/ZWebAPI.UnitTests/Exporters/ExportColumnTests.cs new file mode 100644 index 0000000..581f103 --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/ExportColumnTests.cs @@ -0,0 +1,31 @@ +using ZWebAPI.Exporters; + +namespace ZWebAPI.UnitTests.Exporters +{ + /// + /// Unit tests for . + /// + public class ExportColumnTests + { + /// + /// Test the constructor should expose every value it received. + /// + [Fact] + public void Constructor_Pass_ExposesTheProvidedMetadata() + { + // Arrange + string header = "Header"; + ExportColumnType type = ExportColumnType.Currency; + Func selector = row => row.ToString(); + + // Act + ExportColumn column = new(header, type, selector); + + // Assert + column.Header.Should().Be(header); + column.Type.Should().Be(type); + column.ValueSelector.Should().BeSameAs(selector); + column.ValueSelector(12).Should().Be("12"); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/ExportResultTests.cs b/ZWebAPI.UnitTests/Exporters/ExportResultTests.cs new file mode 100644 index 0000000..dcc93f0 --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/ExportResultTests.cs @@ -0,0 +1,30 @@ +using ZWebAPI.Exporters; + +namespace ZWebAPI.UnitTests.Exporters +{ + /// + /// Unit tests for . + /// + public class ExportResultTests + { + /// + /// Test the constructor should expose every value it received. + /// + [Fact] + public void Constructor_Pass_ExposesTheProvidedPayload() + { + // Arrange + byte[] content = [1, 2, 3]; + string contentType = "text/csv"; + string fileName = "file.csv"; + + // Act + ExportResult result = new(content, contentType, fileName); + + // Assert + result.Content.Should().BeSameAs(content); + result.ContentType.Should().Be(contentType); + result.FileName.Should().Be(fileName); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/ExportValueFormatterTests.cs b/ZWebAPI.UnitTests/Exporters/ExportValueFormatterTests.cs new file mode 100644 index 0000000..d053e88 --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/ExportValueFormatterTests.cs @@ -0,0 +1,222 @@ +using ZWebAPI.Exporters; +using ZWebAPI.UnitTests.Fakes.ExportersFake; + +namespace ZWebAPI.UnitTests.Exporters +{ + /// + /// Unit tests for . + /// + public class ExportValueFormatterTests + { + /// + /// Test the Culture should be the Brazilian Portuguese culture used across every exporter. + /// + [Fact] + public void Culture_Pass_IsBrazilianPortuguese() + { + // Arrange & Act + string name = ExportValueFormatter.Culture.Name; + + // Assert + name.Should().Be("pt-BR"); + } + + /// + /// Test the Format should return an empty string for a null value, whatever the column type. + /// + /// The column type under test. + [Theory] + [InlineData(ExportColumnType.Text)] + [InlineData(ExportColumnType.Number)] + [InlineData(ExportColumnType.Date)] + [InlineData(ExportColumnType.DateTime)] + [InlineData(ExportColumnType.Currency)] + [InlineData(ExportColumnType.Boolean)] + public void Format_Pass_ReturnsEmptyForNullValue(ExportColumnType type) + { + // Arrange + object? value = null; + + // Act + string result = ExportValueFormatter.Format(value, type); + + // Assert + result.Should().BeEmpty(); + } + + /// + /// Test the Format should render a date without its time component. + /// + [Fact] + public void Format_Pass_RendersDateWithoutTime() + { + // Arrange + DateTime value = new(2024, 3, 9, 21, 45, 0); + + // Act + string result = ExportValueFormatter.Format(value, ExportColumnType.Date); + + // Assert + result.Should().Be("09/03/2024"); + } + + /// + /// Test the Format should render a date and time value. + /// + [Fact] + public void Format_Pass_RendersDateTimeWithMinutes() + { + // Arrange + DateTime value = new(2024, 3, 9, 21, 45, 30); + + // Act + string result = ExportValueFormatter.Format(value, ExportColumnType.DateTime); + + // Assert + result.Should().Be("09/03/2024 21:45"); + } + + /// + /// Test the Format should fall back to the plain string when a date column holds another type. + /// + /// The date-like column type under test. + [Theory] + [InlineData(ExportColumnType.Date)] + [InlineData(ExportColumnType.DateTime)] + public void Format_Pass_FallsBackToStringWhenDateColumnHoldsAnotherType(ExportColumnType type) + { + // Arrange + object value = "not a date"; + + // Act + string result = ExportValueFormatter.Format(value, type); + + // Assert + result.Should().Be("not a date"); + } + + /// + /// Test the Format should render currency with the Brazilian symbol and two decimals. + /// + [Fact] + public void Format_Pass_RendersCurrencyWithTwoDecimals() + { + // Arrange + decimal value = 1234.5m; + + // Act + string result = ExportValueFormatter.Format(value, ExportColumnType.Currency); + + // Assert + result.Should().StartWith("R$"); + result.Should().EndWith("1.234,50"); + } + + /// + /// Test the Format should render numbers with a thousands separator and two decimals. + /// + [Fact] + public void Format_Pass_RendersNumberWithTwoDecimals() + { + // Arrange + decimal value = 1234.5m; + + // Act + string result = ExportValueFormatter.Format(value, ExportColumnType.Number); + + // Assert + result.Should().Be("1.234,50"); + } + + /// + /// Test the Format should fall back to the plain string when a numeric column holds a non-formattable value. + /// + /// The numeric column type under test. + [Theory] + [InlineData(ExportColumnType.Currency)] + [InlineData(ExportColumnType.Number)] + public void Format_Pass_FallsBackToStringWhenNumericColumnHoldsNonFormattable(ExportColumnType type) + { + // Arrange + object value = new NonFormattableFake(); + + // Act + string result = ExportValueFormatter.Format(value, type); + + // Assert + result.Should().Be(NonFormattableFake.Text); + } + + /// + /// Test the Format should render booleans in Portuguese. + /// + /// The boolean value under test. + /// The expected rendering. + [Theory] + [InlineData(true, "Sim")] + [InlineData(false, "Não")] + public void Format_Pass_RendersBooleanInPortuguese(bool value, string expected) + { + // Arrange & Act + string result = ExportValueFormatter.Format(value, ExportColumnType.Boolean); + + // Assert + result.Should().Be(expected); + } + + /// + /// Test the Format should fall back to the plain string when a boolean column holds another type. + /// + [Fact] + public void Format_Pass_FallsBackToStringWhenBooleanColumnHoldsAnotherType() + { + // Arrange + object value = 1; + + // Act + string result = ExportValueFormatter.Format(value, ExportColumnType.Boolean); + + // Assert + result.Should().Be("1"); + } + + /// + /// Test the Format should render text columns with the plain string representation. + /// + [Fact] + public void Format_Pass_RendersTextWithPlainString() + { + // Arrange + object value = "plain"; + + // Act + string result = ExportValueFormatter.Format(value, ExportColumnType.Text); + + // Assert + result.Should().Be("plain"); + } + + /// + /// Test the Format should return an empty string when the value renders itself as null. + /// + /// The column type under test. + [Theory] + [InlineData(ExportColumnType.Text)] + [InlineData(ExportColumnType.Number)] + [InlineData(ExportColumnType.Date)] + [InlineData(ExportColumnType.DateTime)] + [InlineData(ExportColumnType.Currency)] + [InlineData(ExportColumnType.Boolean)] + public void Format_Pass_ReturnsEmptyWhenValueRendersAsNull(ExportColumnType type) + { + // Arrange + object value = new NullToStringFake(); + + // Act + string result = ExportValueFormatter.Format(value, type); + + // Assert + result.Should().BeEmpty(); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/Formats/CsvExporterTests.cs b/ZWebAPI.UnitTests/Exporters/Formats/CsvExporterTests.cs new file mode 100644 index 0000000..47eeee3 --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/Formats/CsvExporterTests.cs @@ -0,0 +1,175 @@ +using System.Text; +using ZWebAPI.Exporters; +using ZWebAPI.Exporters.Formats; +using ZWebAPI.UnitTests.Factories; +using ZWebAPI.UnitTests.Fakes.ExportersFake; + +namespace ZWebAPI.UnitTests.Exporters.Formats +{ + /// + /// Unit tests for . + /// + public class CsvExporterTests + { + /// + /// Test the Export should describe the payload as a UTF-8 CSV file. + /// + [Fact] + public void Export_Pass_DescribesThePayloadAsCsv() + { + // Arrange + CsvExporter exporter = new(); + IReadOnlyList columns = ColumnMetadataResolver.Resolve(typeof(ExportRowFake)); + + // Act + ExportResult result = exporter.Export([], columns, "report"); + + // Assert + result.ContentType.Should().Be("text/csv; charset=utf-8"); + result.FileName.Should().Be("report.csv"); + } + + /// + /// Test the Export should prefix the payload with the UTF-8 byte order mark. + /// + [Fact] + public void Export_Pass_WritesTheUtf8ByteOrderMark() + { + // Arrange + CsvExporter exporter = new(); + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text); + + // Act + ExportResult result = exporter.Export([], columns, "report"); + + // Assert + result.Content.Take(3).Should().Equal(Encoding.UTF8.GetPreamble()); + } + + /// + /// Test the Export should write a comma separated header row terminated with CRLF. + /// + [Fact] + public void Export_Pass_WritesTheHeaderRow() + { + // Arrange + CsvExporter exporter = new(); + IReadOnlyList columns = + [ + new("First", ExportColumnType.Text, row => row), + new("Second", ExportColumnType.Text, row => row), + ]; + + // Act + ExportResult result = exporter.Export([], columns, "report"); + + // Assert + ReadBody(result).Should().Be("First,Second\r\n"); + } + + /// + /// Test the Export should write one formatted line per row. + /// + [Fact] + public void Export_Pass_WritesOneFormattedLinePerRow() + { + // Arrange + CsvExporter exporter = new(); + List rows = [new ExportRowFake { ID = 1, Name = "First", IsActive = true }, new ExportRowFake { ID = 2, Name = "Second" }]; + IReadOnlyList columns = + [ + new("ID", ExportColumnType.Text, row => ((ExportRowFake)row).ID), + new("Active", ExportColumnType.Boolean, row => ((ExportRowFake)row).IsActive), + ]; + + // Act + ExportResult result = exporter.Export(rows, columns, "report"); + + // Assert + ReadBody(result).Should().Be("ID,Active\r\n1,Sim\r\n2,Não\r\n"); + } + + /// + /// Test the Export should quote and escape the values that carry CSV control characters. + /// + /// The raw value. + /// The expected escaped rendering. + [Theory] + [InlineData("plain", "plain")] + [InlineData("with,comma", "\"with,comma\"")] + [InlineData("with\"quote", "\"with\"\"quote\"")] + [InlineData("with\rreturn", "\"with\rreturn\"")] + [InlineData("with\nfeed", "\"with\nfeed\"")] + public void Export_Pass_EscapesControlCharacters(string value, string expected) + { + // Arrange + CsvExporter exporter = new(); + List rows = [value]; + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text); + + // Act + ExportResult result = exporter.Export(rows, columns, "report"); + + // Assert + ReadBody(result).Should().Be($"Value\r\n{expected}\r\n"); + } + + /// + /// Test the Export should escape a header carrying CSV control characters. + /// + [Fact] + public void Export_Pass_EscapesTheHeader() + { + // Arrange + CsvExporter exporter = new(); + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text, "Header,with comma"); + + // Act + ExportResult result = exporter.Export([], columns, "report"); + + // Assert + ReadBody(result).Should().Be("\"Header,with comma\"\r\n"); + } + + /// + /// Test the Export should render an empty cell when the value is null. + /// + [Fact] + public void Export_Pass_RendersEmptyCellForNullValue() + { + // Arrange + CsvExporter exporter = new(); + List rows = [ExportColumnFakeFactory.NullValueRow()]; + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text); + + // Act + ExportResult result = exporter.Export(rows, columns, "report"); + + // Assert + ReadBody(result).Should().Be("Value\r\n\r\n"); + } + + /// + /// Test the Export should produce blank lines when there is no column at all. + /// + [Fact] + public void Export_Pass_ProducesBlankLinesWithoutColumns() + { + // Arrange + CsvExporter exporter = new(); + List rows = ["ignored"]; + IReadOnlyList columns = []; + + // Act + ExportResult result = exporter.Export(rows, columns, "report"); + + // Assert + ReadBody(result).Should().Be("\r\n\r\n"); + } + + private static string ReadBody(ExportResult result) + { + return Encoding.UTF8.GetString(result.Content, Encoding.UTF8.GetPreamble().Length, result.Content.Length - Encoding.UTF8.GetPreamble().Length); + } + } +} diff --git a/ZWebAPI.UnitTests/Exporters/Formats/MhtmlExporterTests.cs b/ZWebAPI.UnitTests/Exporters/Formats/MhtmlExporterTests.cs new file mode 100644 index 0000000..6dc809e --- /dev/null +++ b/ZWebAPI.UnitTests/Exporters/Formats/MhtmlExporterTests.cs @@ -0,0 +1,250 @@ +using System.Text; +using ZWebAPI.Exporters; +using ZWebAPI.Exporters.Formats; +using ZWebAPI.UnitTests.Factories; +using ZWebAPI.UnitTests.Fakes.ExportersFake; + +namespace ZWebAPI.UnitTests.Exporters.Formats +{ + /// + /// Unit tests for . + /// + public class MhtmlExporterTests + { + /// + /// Test the Export should describe the payload as a web archive. + /// + [Fact] + public void Export_Pass_DescribesThePayloadAsWebArchive() + { + // Arrange + MhtmlExporter exporter = new(); + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text); + + // Act + ExportResult result = exporter.Export([], columns, "report"); + + // Assert + result.ContentType.Should().Be("message/rfc822"); + result.FileName.Should().Be("report.mhtml"); + } + + /// + /// Test the Export should wrap the document in a multipart related MIME envelope. + /// + [Fact] + public void Export_Pass_WrapsTheDocumentInAMimeEnvelope() + { + // Arrange + MhtmlExporter exporter = new(); + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text); + + // Act + ExportResult result = exporter.Export([], columns, "report"); + + // Assert + string mime = Encoding.UTF8.GetString(result.Content); + mime.Should().StartWith("MIME-Version: 1.0\r\n"); + mime.Should().Contain("Content-Type: multipart/related; boundary=\"----=_NextPart_ZWebAPI_Export\""); + mime.Should().Contain("Content-Type: text/html; charset=\"utf-8\""); + mime.Should().Contain("Content-Transfer-Encoding: quoted-printable"); + mime.Should().Contain("Content-Location: export.html"); + mime.Should().EndWith("------=_NextPart_ZWebAPI_Export--\r\n"); + } + + /// + /// Test the Export should render the headers and the formatted cells of every row. + /// + [Fact] + public void Export_Pass_RendersHeadersAndRows() + { + // Arrange + MhtmlExporter exporter = new(); + List rows = + [ + new ExportRowFake { ID = 1, IsActive = true }, + new ExportRowFake { ID = 2 }, + ]; + IReadOnlyList columns = + [ + new("Identifier", ExportColumnType.Text, row => ((ExportRowFake)row).ID), + new("Active", ExportColumnType.Boolean, row => ((ExportRowFake)row).IsActive), + ]; + + // Act + ExportResult result = exporter.Export(rows, columns, "report"); + + // Assert + string html = DecodeQuotedPrintable(Encoding.UTF8.GetString(result.Content)); + html.Should().Contain("IdentifierActive"); + html.Should().Contain("1Sim"); + html.Should().Contain("2Não"); + } + + /// + /// Test the Export should HTML-encode the headers and the values. + /// + [Fact] + public void Export_Pass_HtmlEncodesHeadersAndValues() + { + // Arrange + MhtmlExporter exporter = new(); + List rows = [""]; + IReadOnlyList columns = ExportColumnFakeFactory.Identity(ExportColumnType.Text, "Header & "); + + // Act + ExportResult result = exporter.Export(rows, columns, "report"); + + // Assert + string html = DecodeQuotedPrintable(Encoding.UTF8.GetString(result.Content)); + html.Should().Contain("Header & <tag>"); + html.Should().Contain("<script>"); + html.Should().NotContain("