-
Notifications
You must be signed in to change notification settings - Fork 0
Add CI workflow and unit tests for HubDocs functionality #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EndpointDataSource>() | ||
| .Endpoints | ||
| .OfType<RouteEndpoint>() | ||
| .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<SimpleHub>("/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<Dictionary<Type, string>>(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<Type, string> | ||
| { | ||
| { 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<IEnumerable<HubMetadata>>(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<string>(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<string>( | ||
| method!.Invoke(null, new object[] { typeof(Dictionary<string, List<int?>>) })); | ||
|
|
||
| // Assert | ||
| Assert.Equal("Dictionary<String, List<Nullable<Int32>>>", 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<string>(method!.Invoke(null, new object[] { parameter })); | ||
|
|
||
| // Assert | ||
| Assert.Equal("Nullable<Int32>?", 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<bool>(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<bool>(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<IEnumerable<MethodInfo>>(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) | ||
| { | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unused test fixture method
|
||
|
|
||
| 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<ITypedClient> | ||
| { | ||
| 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 | ||
| { | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WebApplication not cleaned up on test assertion failure
Medium Severity
Both tests that call
app.StartAsync()placeapp.StopAsync()after assertions without atry/finallyblock. If any assertion betweenStartAsyncandStopAsyncthrows, theWebApplicationis never stopped, leaving the port bound. Since both tests are in the same class (xUnit runs them sequentially), a failure in the first test will cascade into a port-conflict failure in the second test, making debugging harder.Additional Locations (1)
tests/HubDocs.UnitTests/ExtensionsInternalTests.cs#L53-L57Reviewed by Cursor Bugbot for commit 1525785. Configure here.