diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a19a793..c3aab36 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -137,6 +137,7 @@ jobs:
shell: bash
run: |
node -e "const f=require('./index.js'); if(!f.FusionApp||!f.status) throw new Error('smoke failed'); console.log('ok', f.status.HTTP_SUCCESS)"
+ node --test ../../tests/node/unit/*.test.js
csharp:
name: C# package
@@ -152,3 +153,5 @@ jobs:
run: cargo build -p fusion-ffi --release
- name: Build FusionFramework
run: dotnet build -c Release bindings/csharp/FusionFramework/FusionFramework.csproj
+ - name: Test C# bindings
+ run: dotnet test tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj -c Release
diff --git a/bindings/csharp/FusionFramework/FusionFramework.csproj b/bindings/csharp/FusionFramework/FusionFramework.csproj
index 1bd4cc0..dde36f0 100644
--- a/bindings/csharp/FusionFramework/FusionFramework.csproj
+++ b/bindings/csharp/FusionFramework/FusionFramework.csproj
@@ -22,6 +22,9 @@
false
+
+
+
diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs
index 88f6e95..d395e2e 100644
--- a/bindings/csharp/FusionFramework/Swagger.cs
+++ b/bindings/csharp/FusionFramework/Swagger.cs
@@ -219,6 +219,22 @@ static JsonObject BuildOpenApi(SwaggerConfig swagger, string? version = null)
return spec;
}
+ internal static JsonObject CreateTestSpec(string? version = null)
+ {
+ var swagger = new SwaggerConfig
+ {
+ Path = "/swagger",
+ PageTitle = "Fusion API Docs",
+ Info = new JsonObject
+ {
+ ["title"] = "fusion-framework",
+ ["version"] = "1.0.0",
+ },
+ Ui = new JsonObject(),
+ };
+ return BuildOpenApi(swagger, version);
+ }
+
static void FillPaths(JsonObject paths, string? versionFilter)
{
foreach (var entry in Route.Snapshot())
diff --git a/bindings/csharp/FusionFramework/Testing/FusionTestSupport.cs b/bindings/csharp/FusionFramework/Testing/FusionTestSupport.cs
new file mode 100644
index 0000000..4f926ab
--- /dev/null
+++ b/bindings/csharp/FusionFramework/Testing/FusionTestSupport.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Nodes;
+
+namespace FusionFramework.Testing;
+
+/// Helpers used by the repository test suite (not required at runtime).
+public static class FusionTestSupport
+{
+ public static JsonObject OpenApiSpec(string? version = null) => SwaggerDocs.CreateTestSpec(version);
+
+ public static void ClearRoutes() => Route.Clear();
+}
diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js
index 1a08ec8..71594c3 100644
--- a/crates/fusion-node/index.js
+++ b/crates/fusion-node/index.js
@@ -924,6 +924,40 @@ function collectRouteVersions() {
return { versions, hasUnversioned }
}
+function clearRouteRegistry() {
+ registry.length = 0
+}
+
+function testSwaggerConfig() {
+ return {
+ path: '/swagger',
+ info: { title: 'fusion-framework', version: '1.0.0' },
+ servers: [],
+ auth: { schemes: {}, global: [], oauth: {} },
+ navbar: {
+ enabled: true,
+ showUrlInput: false,
+ showUrlInputSet: true,
+ urlsSet: false,
+ urls: [],
+ },
+ ui: {},
+ pageTitle: 'Fusion API Docs',
+ }
+}
+
+function openapiSpec(version = null) {
+ return buildOpenApi(testSwaggerConfig(), version)
+}
+
+function routeVersions() {
+ return collectRouteVersions().versions
+}
+
+function hasUnversionedRoutes() {
+ return collectRouteVersions().hasUnversioned
+}
+
function swaggerVersionUrls(prefix) {
const { versions, hasUnversioned } = collectRouteVersions()
const urls = versions.map((label) => ({
@@ -1468,6 +1502,10 @@ module.exports = {
parsePagination,
paginatedBody,
renderTemplate,
+ clearRouteRegistry,
+ openapiSpec,
+ routeVersions,
+ hasUnversionedRoutes,
getHttpMethods: () => HTTP_METHODS,
apiResourceNameJs: native.apiResourceNameJs,
resolveRoutePathJs: native.resolveRoutePathJs,
diff --git a/tests/README.md b/tests/README.md
index f1f2c6b..766c84e 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -6,19 +6,19 @@ Central test layout for the monorepo. Binding code stays in `crates/` and `bindi
```
tests/
-├── python/ # pytest — primary binding test suite
-│ ├── conftest.py # route/middleware isolation per test
-│ └── unit/ # fast, no live server
-├── fixtures/ # shared JSON, templates, sample apps
-└── scripts/ # local runners (CI uses workflow steps)
+├── python/ # pytest
+├── node/ # node --test
+├── csharp/ # dotnet test (xUnit)
+├── fixtures/
+└── scripts/
```
| Layer | Location | Runner |
|-------|----------|--------|
| Rust core | `crates/fusion-core/src/**/*.rs` (`#[cfg(test)]`) | `cargo test -p fusion-core` |
-| Python binding | `tests/python/` | `pytest` (see below) |
-| Node | syntax + smoke in CI | `node --check crates/fusion-node/index.js` |
-| C# | build in CI | `dotnet build bindings/csharp/...` |
+| Python binding | `tests/python/` | `pytest` |
+| Node binding | `tests/node/` | `node --test` (see below) |
+| C# binding | `tests/csharp/` | `dotnet test` |
## Python (pytest)
@@ -43,6 +43,28 @@ Run one file:
pytest tests/python/unit/test_http_route.py -q
```
+## Node (`node --test`)
+
+Build the native addon first:
+
+```bash
+cd crates/fusion-node && npm install && npm run build:debug
+```
+
+Run tests:
+
+```bash
+./tests/scripts/run-node.sh
+```
+
+## C# (xUnit)
+
+```bash
+./tests/scripts/run-csharp.sh
+```
+
+Requires `fusion_ffi` built (`cargo build -p fusion-ffi`).
+
## Full local verification
```bash
diff --git a/tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj b/tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj
new file mode 100644
index 0000000..430e16c
--- /dev/null
+++ b/tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ FusionFramework.Tests
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
diff --git a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs
new file mode 100644
index 0000000..8002422
--- /dev/null
+++ b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs
@@ -0,0 +1,76 @@
+using System.Text.Json.Nodes;
+using FusionFramework;
+using Xunit;
+
+namespace FusionFramework.Tests;
+
+public class MiddlewareTests
+{
+ static object Handler(FusionRequest request) =>
+ new Dictionary
+ {
+ ["status"] = 200,
+ ["body"] = new Dictionary { ["state"] = request.State },
+ };
+
+ [Fact]
+ public void BearerJwt_populates_state()
+ {
+ const string token =
+ "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0.";
+ var request = new FusionRequest
+ {
+ Headers = new Dictionary { ["Authorization"] = $"Bearer {token}" },
+ };
+
+ var result = Middleware.RunChain(
+ request,
+ new[] { Middleware.BearerJwt() },
+ Handler) as Dictionary;
+
+ Assert.NotNull(result);
+ Assert.Equal(200, result["status"]);
+ var body = Assert.IsType>(result["body"]);
+ var state = Assert.IsType>(body["state"]);
+ Assert.Equal("1", state["jwt"]!["sub"]!.GetValue());
+ }
+
+ [Fact]
+ public void RequireRoles_blocks_missing_role()
+ {
+ var request = new FusionRequest
+ {
+ State = new Dictionary
+ {
+ ["jwt"] = System.Text.Json.Nodes.JsonNode.Parse("{\"roles\":[\"user\"]}"),
+ },
+ };
+
+ var result = Middleware.RunChain(
+ request,
+ new[] { Middleware.RequireRoles("admin") },
+ Handler) as Dictionary;
+
+ Assert.NotNull(result);
+ Assert.Equal(403, result["status"]);
+ }
+
+ [Fact]
+ public void Cors_answers_options_with_204()
+ {
+ var request = new FusionRequest
+ {
+ Method = "OPTIONS",
+ Path = "/api",
+ Headers = new Dictionary { ["Origin"] = "https://example.com" },
+ };
+
+ var result = Middleware.RunChain(request, new[] { Middleware.Cors() }, Handler)
+ as Dictionary;
+
+ Assert.NotNull(result);
+ Assert.Equal(204, result["status"]);
+ var headers = Assert.IsType>(result["headers"]);
+ Assert.False(string.IsNullOrEmpty(headers["Access-Control-Allow-Origin"]));
+ }
+}
diff --git a/tests/csharp/FusionFramework.Tests/RouteTests.cs b/tests/csharp/FusionFramework.Tests/RouteTests.cs
new file mode 100644
index 0000000..2957579
--- /dev/null
+++ b/tests/csharp/FusionFramework.Tests/RouteTests.cs
@@ -0,0 +1,77 @@
+using System.Text.Json.Nodes;
+using FusionFramework;
+using FusionFramework.Testing;
+using Xunit;
+
+namespace FusionFramework.Tests;
+
+public class RouteTests
+{
+ public RouteTests() => FusionTestSupport.ClearRoutes();
+
+ [Fact]
+ public void ResolvePath_expands_module_token()
+ {
+ var path = Route.ResolvePath("/api/[module]", "ProductModule");
+ Assert.Equal("/api/product", path);
+ }
+
+ [Fact]
+ public void Register_mounts_convention_and_custom_http_slots()
+ {
+ Route.Register(typeof(ProductModule), "/api/[module]");
+
+ var entry = Assert.Single(Route.Snapshot());
+ Assert.Equal("/api/product", entry.ClassBasePath);
+ Assert.Contains(entry.Slots, s => s.Path == "/api/product" && s.HttpMethod == "get");
+ Assert.Contains(entry.Slots, s => s.Path == "/api/product/catalog/catalog" && s.HttpMethod == "get");
+ }
+
+ [Fact]
+ public void OpenApi_lists_registered_api_paths()
+ {
+ Route.Register(typeof(ProductModule), "/api/[module]", tags: new[] { "products" });
+
+ var spec = FusionTestSupport.OpenApiSpec();
+ var paths = spec["paths"]!.AsObject();
+ Assert.True(paths.ContainsKey("/api/product"));
+ Assert.True(paths["/api/product"]!.AsObject().ContainsKey("get"));
+ }
+
+ [Fact]
+ public void Template_routes_are_omitted_from_openapi()
+ {
+ Route.Register(typeof(SampleHomePage), "/pages/home");
+ Route.Register(typeof(ProductModule), "/api/[module]", version: "v1");
+
+ var combined = FusionTestSupport.OpenApiSpec();
+ Assert.False(combined["paths"]!.AsObject().ContainsKey("/pages/home"));
+
+ var v1 = FusionTestSupport.OpenApiSpec("v1");
+ Assert.False(v1["paths"]!.AsObject().ContainsKey("/pages/home"));
+ Assert.True(v1["paths"]!.AsObject().ContainsKey("/v1/api/product"));
+ }
+
+ [Route("/api/[module]")]
+ sealed class ProductModule : FusionBaseApi
+ {
+ public object Get() => Response(new { ok = true });
+
+ [HttpGet("catalog/[action]")]
+ public object CatalogAction() => Response(new { items = Array.Empty