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
61 changes: 61 additions & 0 deletions .github/workflows/ci-build-test.yml
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
272 changes: 272 additions & 0 deletions tests/HubDocs.UnitTests/ExtensionsInternalTests.cs
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();
Copy link
Copy Markdown

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() place app.StopAsync() after assertions without a try/finally block. If any assertion between StartAsync and StopAsync throws, the WebApplication is 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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1525785. Configure here.

}

[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)
{
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused test fixture method AcceptsNullableRef

Low Severity

AcceptsNullableRef(string? value) in NullableHolder is never referenced by any test. The existing tests use AcceptsNullableValue and AcceptsNonNullableRef, but no test exercises the nullable reference type case. This is dead code that may also indicate a missing test for nullable reference type detection via IsNullable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1525785. Configure here.


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
{
}
}
29 changes: 29 additions & 0 deletions tests/HubDocs.UnitTests/HubDocsAttributeTests.cs
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);
}
}
Loading
Loading