Skip to content

Commit

Permalink
Initial unit tests (#6)
Browse files Browse the repository at this point in the history
* added test project

* added code coverage tooling

Per directions on https://seankilleen.com/2024/03/beautiful-net-test-reports-using-github-actions/

* only run code coverage reporting on Linux

* basic sanity checks for NonZeroUInt32

* added topic validator
  • Loading branch information
Aaronontheweb authored Apr 13, 2024
1 parent 863ab50 commit ad71345
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 1 deletion.
52 changes: 51 additions & 1 deletion .github/workflows/pr_validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ on:

jobs:
test:
# Permissions this GitHub Action needs for other things in GitHub
permissions: write-all
name: Test-${{matrix.os}}
runs-on: ${{matrix.os}}

Expand All @@ -39,4 +41,52 @@ jobs:
run: dotnet build -c Release

- name: "dotnet test"
run: dotnet test -c Release
run: dotnet test --configuration Release --verbosity normal --logger trx --collect:"XPlat Code Coverage"

- name: Combine Coverage Reports
if: runner.os == 'Linux'
uses: danielpalme/[email protected]
with:
reports: "**/*.cobertura.xml"
targetdir: "${{ github.workspace }}"
reporttypes: "Cobertura"
verbosity: "Info"
title: "Code Coverage"
tag: "${{ github.run_number }}_${{ github.run_id }}"
customSettings: ""
toolpath: "reportgeneratortool"

- name: Publish Code Coverage Report
if: runner.os == 'Linux'
uses: irongut/[email protected]
with:
filename: "Cobertura.xml"
badge: true
fail_below_min: false
format: markdown
hide_branch_rate: false
hide_complexity: false
indicators: true
output: both
thresholds: "10 30"

- name: Add Coverage PR Comment
if: github.event_name == 'pull_request' && runner.os == 'Linux'
uses: marocchino/sticky-pull-request-comment@v2
with:
recreate: true
path: code-coverage-results.md

- name: Upload Test Result Files
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: test-results
path: ${{ github.workspace }}/**/TestResults/**/*
retention-days: 5

- name: Publish Test Results
if: always() && runner.os == 'Linux'
uses: EnricoMi/[email protected]
with:
trx_files: "${{ github.workspace }}/**/*.trx"
11 changes: 11 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,15 @@
<ItemGroup>
<PackageVersion Include="Akka.Hosting" Version="1.5.18" />
</ItemGroup>

<!-- Test Package Versions -->
<ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="xunit" Version="2.7.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="coverlet.collector" Version="3.2.0" />
<PackageVersion Include="Verify.Xunit" Version="17.10.2" />
<PackageVersion Include="Verify.DiffPlex" Version="1.3.0" />
</ItemGroup>
</Project>
14 changes: 14 additions & 0 deletions TurboMqtt.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TurboMqtt.Core", "src\TurboMqtt.Core\TurboMqtt.Core.csproj", "{BE905781-3D96-44F5-A230-E06FD5213C1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TurboMqtt.Core.Tests", "tests\TurboMqtt.Core.Tests\TurboMqtt.Core.Tests.csproj", "{8090B5F4-3C72-451E-8EC1-F73F8296A765}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{B3AABB6F-ACB0-4CFB-A17B-6B2189369139}"
ProjectSection(SolutionItems) = preProject
Directory.Build.props = Directory.Build.props
Directory.Packages.props = Directory.Packages.props
global.json = global.json
nuget.config = nuget.config
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -18,5 +28,9 @@ Global
{BE905781-3D96-44F5-A230-E06FD5213C1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BE905781-3D96-44F5-A230-E06FD5213C1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BE905781-3D96-44F5-A230-E06FD5213C1C}.Release|Any CPU.Build.0 = Release|Any CPU
{8090B5F4-3C72-451E-8EC1-F73F8296A765}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8090B5F4-3C72-451E-8EC1-F73F8296A765}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8090B5F4-3C72-451E-8EC1-F73F8296A765}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8090B5F4-3C72-451E-8EC1-F73F8296A765}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
77 changes: 77 additions & 0 deletions src/TurboMqtt.Core/MqttTopicValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// -----------------------------------------------------------------------
// <copyright file="MqttTopicValidator.cs" company="Petabridge, LLC">
// Copyright (C) 2024 - 2024 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------

namespace TurboMqtt.Core;

public static class MqttTopicValidator
{
/// <summary>
/// Validates an MQTT topic ID for publishing or subscribing.
/// </summary>
/// <param name="topic">The topic to validate.</param>
/// <param name="forSubscription">Specifies whether the validation is for subscribing (true) or publishing (false).</param>
/// <returns>A tuple indicating whether the topic is valid and an error message if it is not.</returns>
public static (bool IsValid, string ErrorMessage) ValidateTopic(string topic, bool forSubscription = true)
{
// Check if the topic is empty
if (string.IsNullOrEmpty(topic))
{
return (false, "Topic must not be empty.");
}

// Check if the topic contains the null character
if (topic.Contains('\0'))
{
return (false, "Topic must not contain null characters.");
}

// Check for invalid wildcard usage if for publishing
if (!forSubscription)
{
if (topic.Contains('+') || topic.Contains('#'))
{
return (false, "Wildcards ('+' and '#') are not allowed in topics for publishing.");
}
}

// Validate wildcards for subscription
if (forSubscription)
{
// Validate '+'
int indexOfPlus = topic.IndexOf('+');
while (indexOfPlus != -1)
{
if ((indexOfPlus > 0 && topic[indexOfPlus - 1] != '/') ||
(indexOfPlus < topic.Length - 1 && topic[indexOfPlus + 1] != '/'))
{
return (false, "Single-level wildcard '+' must be located between slashes or at the beginning/end of the topic.");
}
indexOfPlus = topic.IndexOf('+', indexOfPlus + 1);
}

// Validate '#'
if (topic.Contains('#') && !topic.EndsWith("/#") && !topic.Equals("#"))
{
return (false, "Multi-level wildcard '#' must be at the end of the topic or after a '/'.");
}
}

// Check for forbidden use of '$' at the beginning by clients
if (topic.StartsWith($"$"))
{
return (false, "Topics starting with '$' are reserved and should not be used by clients for publishing.");
}

// Optional: Check for excessive length (implementation-specific limit)
if (topic.Length > 65535) // Example maximum length
{
return (false, "Topic exceeds the maximum length allowed.");
}

// If all checks pass
return (true, "Topic is valid.");
}
}
3 changes: 3 additions & 0 deletions src/TurboMqtt.Core/Properties/Friends.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("TurboMqtt.Core.Tests")]
2 changes: 2 additions & 0 deletions tests/TurboMqtt.Core.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using Xunit;
global using FluentAssertions;
51 changes: 51 additions & 0 deletions tests/TurboMqtt.Core.Tests/MqttTopicValidatorSpecs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// -----------------------------------------------------------------------
// <copyright file="MqttTopicValidatorSpecs.cs" company="Petabridge, LLC">
// Copyright (C) 2024 - 2024 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------

namespace TurboMqtt.Core.Tests;

public class MqttTopicValidatorSpecs
{
public static readonly TheoryData<string> FailureCases = new TheoryData<string>(

"foo/bar+", // invalid wildcard position
"home/kit+chen/light", // invalid wildcard position
"home/+/kitchen+", // invalid wildcard position
"home+kitchen/light", // invalid wildcard position

// generate some invalid uses of the '#' wildcard
"foo/#/bar", // invalid wildcard position

// generate some invalid uses of the '$' characterq
"$foo/bar", // invalid use of '$' character

// generate some invalid uses of the null character
"foo\0bar" // invalid use of null character
);

[Theory]
[MemberData(nameof(FailureCases))]
public void ShouldFailValidationForTopicSubscription(string topic)
{
var result = MqttTopicValidator.ValidateTopic(topic, true);
result.IsValid.Should().BeFalse();
}

public static readonly TheoryData<string> SuccessCases = new TheoryData<string>(
"home/kitchen/light",
"home/kitchen/temperature",
"home/kitchen/humidity",
"home/+/pressure",
"home/kitchen/pressure/#"
);

[Theory]
[MemberData(nameof(SuccessCases))]
public void ShouldPassValidationForTopicSubscription(string topic)
{
var result = MqttTopicValidator.ValidateTopic(topic, true);
result.IsValid.Should().BeTrue();
}
}
17 changes: 17 additions & 0 deletions tests/TurboMqtt.Core.Tests/NonZeroUintTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace TurboMqtt.Core.Tests;

public class NonZeroUintTests
{
[Fact]
public void ShouldFailOnZero()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new NonZeroUInt32(0));
}

[Fact]
public void ShouldSucceedOnNonZero()
{
var nonZero = new NonZeroUInt32(1);
Assert.Equal(1u, nonZero.Value);
}
}
30 changes: 30 additions & 0 deletions tests/TurboMqtt.Core.Tests/TurboMqtt.Core.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit"/>
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\TurboMqtt.Core\TurboMqtt.Core.csproj" />
</ItemGroup>

</Project>

0 comments on commit ad71345

Please sign in to comment.