diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml new file mode 100644 index 0000000..5494f5c --- /dev/null +++ b/.github/workflows/ci-build-test.yml @@ -0,0 +1,61 @@ +name: CI Build and Test + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore HubDocs.sln + + - name: Build + run: dotnet build HubDocs.sln -c Release --no-restore + + - name: Run unit tests + run: dotnet test tests/HubDocs.UnitTests/HubDocs.UnitTests.csproj -c Release --no-build --collect:"XPlat Code Coverage" + + - name: Install ReportGenerator + if: always() + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + + - name: Generate HTML coverage report + if: always() + run: | + reportgenerator \ + -reports:"tests/HubDocs.UnitTests/TestResults/**/coverage.cobertura.xml" \ + -targetdir:"coverage-report" \ + -reporttypes:"Html;MarkdownSummary" + + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-coverage-cobertura + path: tests/HubDocs.UnitTests/TestResults/**/coverage.cobertura.xml + + - name: Upload HTML coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-coverage-html + path: coverage-report diff --git a/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs b/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs new file mode 100644 index 0000000..ef32409 --- /dev/null +++ b/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs @@ -0,0 +1,272 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; + +namespace HubDocs.UnitTests; + +public class ExtensionsInternalTests +{ + [Fact] + public async Task AddHubDocs_WhenCalled_ShouldRegisterExpectedRoutes() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSignalR(); + var app = builder.Build(); + + // Act + app.AddHubDocs(); + await app.StartAsync(); + + // Assert + var routeEndpoints = app.Services + .GetRequiredService() + .Endpoints + .OfType() + .Select(e => e.RoutePattern.RawText) + .ToList(); + + Assert.Contains("/hubdocs/hubdocs.json", routeEndpoints); + Assert.Contains("/hubdocs/index.html", routeEndpoints); + Assert.Contains("/hubdocs", routeEndpoints); + + await app.StopAsync(); + } + + [Fact] + public async Task GetHubRoutesFromEndpoints_WhenHubMapped_ShouldReturnRoute() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSignalR(); + var app = builder.Build(); + app.MapHub("/hubs/simple"); + + var method = typeof(Extensions).GetMethod( + "GetHubRoutesFromEndpoints", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + // Act + await app.StartAsync(); + var result = method!.Invoke(null, new object[] { app }); + var routes = Assert.IsAssignableFrom>(result); + await app.StopAsync(); + + // Assert + Assert.Equal("/hubs/simple", routes[typeof(SimpleHub)]); + } + + [Fact] + public void DiscoverSignalRHubs_WhenStronglyTypedHubPresent_ShouldIncludeClientMethods() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "DiscoverSignalRHubs", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var routes = new Dictionary + { + { typeof(TypedHub), "/hubs/typed" }, + { typeof(UnattributedHub), "/hubs/unattributed" } + }; + + // Act + var result = method!.Invoke(null, new object[] { routes, new[] { typeof(TypedHub).Assembly } }); + var discovered = Assert.IsAssignableFrom>(result).ToList(); + + // Assert + var typed = Assert.Single(discovered, h => h.HubName == nameof(TypedHub)); + Assert.Equal("/hubs/typed", typed.Path); + Assert.Equal(typeof(ITypedClient).FullName, typed.ClientInterfaceName); + Assert.NotNull(typed.ClientMethods); + Assert.Contains(typed.ClientMethods!, m => m.MethodName == nameof(ITypedClient.Notify)); + Assert.Contains(typed.Methods, m => m.MethodName == nameof(TypedHub.Send)); + Assert.DoesNotContain(discovered, h => h.HubName == nameof(UnattributedHub)); + Assert.DoesNotContain(discovered, h => h.HubName == nameof(NotRegisteredHub)); + } + + [Fact] + public void GetMethodSignature_WhenMethodHasParameters_ShouldIncludeParameterTypeNames() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "GetMethodSignature", + BindingFlags.NonPublic | BindingFlags.Static); + var targetMethod = typeof(SignatureHolder).GetMethod(nameof(SignatureHolder.Work)); + + Assert.NotNull(method); + Assert.NotNull(targetMethod); + + // Act + var signature = Assert.IsType(method!.Invoke(null, new object[] { targetMethod! })); + + // Assert + Assert.Equal("Work(System.Int32,System.String)", signature); + } + + [Fact] + public void FormatType_WhenTypeIsGeneric_ShouldReturnReadableGenericName() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "FormatType", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + // Act + var formatted = Assert.IsType( + method!.Invoke(null, new object[] { typeof(Dictionary>) })); + + // Assert + Assert.Equal("Dictionary>>", formatted); + } + + [Fact] + public void FormatParameter_WhenParameterIsNullableValueType_ShouldAppendQuestionMark() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "FormatParameter", + BindingFlags.NonPublic | BindingFlags.Static); + + var targetMethod = typeof(NullableHolder).GetMethod(nameof(NullableHolder.AcceptsNullableValue)); + var parameter = targetMethod!.GetParameters().Single(); + + Assert.NotNull(method); + + // Act + var formatted = Assert.IsType(method!.Invoke(null, new object[] { parameter })); + + // Assert + Assert.Equal("Nullable?", formatted); + } + + [Fact] + public void IsNullable_WhenParameterIsNullableValueType_ShouldReturnTrue() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "IsNullable", + BindingFlags.NonPublic | BindingFlags.Static); + + var targetMethod = typeof(NullableHolder).GetMethod(nameof(NullableHolder.AcceptsNullableValue)); + var parameter = targetMethod!.GetParameters().Single(); + + Assert.NotNull(method); + + // Act + var result = Assert.IsType(method!.Invoke(null, new object[] { parameter })); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNullable_WhenParameterIsNonNullableReferenceType_ShouldReturnFalse() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "IsNullable", + BindingFlags.NonPublic | BindingFlags.Static); + + var targetMethod = typeof(NullableHolder).GetMethod(nameof(NullableHolder.AcceptsNonNullableRef)); + var parameter = targetMethod!.GetParameters().Single(); + + Assert.NotNull(method); + + // Act + var result = Assert.IsType(method!.Invoke(null, new object[] { parameter })); + + // Assert + Assert.False(result); + } + + [Fact] + public void GetAllPublicHubMethods_WhenDerivedOverridesBaseByName_ShouldExcludeBaseMethodWithSameName() + { + // Arrange + var method = typeof(Extensions).GetMethod( + "GetAllPublicHubMethods", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + // Act + var result = method!.Invoke(null, new object[] { typeof(DerivedHub) }); + var methods = Assert.IsAssignableFrom>(result).ToList(); + + // Assert + Assert.Contains(methods, m => m.Name == nameof(DerivedHub.DerivedOnly)); + Assert.Contains(methods, m => m.Name == nameof(BaseHub.BaseOnly)); + Assert.Single(methods, m => m.Name == nameof(BaseHub.SharedName)); + } + + public class SignatureHolder + { + public void Work(int a, string b) + { + } + } + + public class NullableHolder + { + public void AcceptsNullableRef(string? value) + { + } + + public void AcceptsNonNullableRef(string value) + { + } + + public void AcceptsNullableValue(int? value) + { + } + } + + public class BaseHub : Hub + { + public virtual Task SharedName(string data) => Task.CompletedTask; + + public Task BaseOnly() => Task.CompletedTask; + } + + public class DerivedHub : BaseHub + { + public override Task SharedName(string data) => Task.CompletedTask; + + public Task DerivedOnly() => Task.CompletedTask; + } + + public interface ITypedClient + { + Task Notify(string message); + } + + [HubDocs] + public class TypedHub : Hub + { + public Task Send(string? message, int? code) => Task.CompletedTask; + } + + public class UnattributedHub : Hub + { + public Task Ping() => Task.CompletedTask; + } + + [HubDocs] + public class NotRegisteredHub : Hub + { + public Task MissingRoute() => Task.CompletedTask; + } + + public class SimpleHub : Hub + { + } +} diff --git a/tests/HubDocs.UnitTests/HubDocsAttributeTests.cs b/tests/HubDocs.UnitTests/HubDocsAttributeTests.cs new file mode 100644 index 0000000..afe9e8f --- /dev/null +++ b/tests/HubDocs.UnitTests/HubDocsAttributeTests.cs @@ -0,0 +1,29 @@ +namespace HubDocs.UnitTests; + +public class HubDocsAttributeTests +{ + [Fact] + public void HubDocsAttribute_WhenQueried_ShouldHaveExpectedUsageMetadata() + { + // Arrange + var usage = (AttributeUsageAttribute?)Attribute.GetCustomAttribute( + typeof(HubDocsAttribute), + typeof(AttributeUsageAttribute)); + + // Assert + Assert.NotNull(usage); + Assert.Equal(AttributeTargets.Class, usage!.ValidOn); + Assert.False(usage.AllowMultiple); + Assert.False(usage.Inherited); + } + + [Fact] + public void HubDocsAttribute_WhenCreated_ShouldInstantiateSuccessfully() + { + // Act + var attribute = new HubDocsAttribute(); + + // Assert + Assert.NotNull(attribute); + } +} diff --git a/tests/HubDocs.UnitTests/HubRouteRegistryStateTests.cs b/tests/HubDocs.UnitTests/HubRouteRegistryStateTests.cs new file mode 100644 index 0000000..f93dad4 --- /dev/null +++ b/tests/HubDocs.UnitTests/HubRouteRegistryStateTests.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.SignalR; + +namespace HubDocs.UnitTests; + +public class HubRouteRegistryStateTests +{ + [Fact] + public void AddMapping_WhenCalled_ShouldAppendMapping() + { + // Arrange + var before = HubRouteRegistry.GetMappings().Count; + var path = $"/hubs/state-{Guid.NewGuid():N}"; + + // Act + HubRouteRegistry.AddMapping(path); + var after = HubRouteRegistry.GetMappings(); + + // Assert + Assert.Equal(before + 1, after.Count); + var added = after.Last(); + Assert.Equal(typeof(TestHub), added.HubType); + Assert.Equal(path, added.Path); + } + + [Fact] + public void GetMappings_WhenCalled_ShouldReturnReadOnlyCollection() + { + // Act + var mappings = HubRouteRegistry.GetMappings(); + + // Assert + Assert.IsAssignableFrom>(mappings); + } + + private class TestHub : Hub + { + } +}