From fab0478679466caf97aad0b03ea9eaa962c12065 Mon Sep 17 00:00:00 2001 From: ehsan amiri Date: Thu, 3 Sep 2026 16:21:33 +0330 Subject: [PATCH] Add Node and C# unit tests to the centralized harness. Expose test helpers in Node/C# bindings and wire CI plus run scripts for the new suites. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 + .../FusionFramework/FusionFramework.csproj | 3 + bindings/csharp/FusionFramework/Swagger.cs | 16 ++ .../Testing/FusionTestSupport.cs | 11 ++ crates/fusion-node/index.js | 38 +++++ tests/README.md | 38 ++++- .../FusionFramework.Tests.csproj | 22 +++ .../FusionFramework.Tests/MiddlewareTests.cs | 76 +++++++++ .../FusionFramework.Tests/RouteTests.cs | 77 +++++++++ tests/node/helpers/load-fusion.js | 4 + tests/node/unit/bindings.test.js | 146 ++++++++++++++++++ tests/scripts/run-all.sh | 13 +- tests/scripts/run-csharp.sh | 10 ++ tests/scripts/run-node.sh | 12 ++ 14 files changed, 458 insertions(+), 11 deletions(-) create mode 100644 bindings/csharp/FusionFramework/Testing/FusionTestSupport.cs create mode 100644 tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj create mode 100644 tests/csharp/FusionFramework.Tests/MiddlewareTests.cs create mode 100644 tests/csharp/FusionFramework.Tests/RouteTests.cs create mode 100644 tests/node/helpers/load-fusion.js create mode 100644 tests/node/unit/bindings.test.js create mode 100755 tests/scripts/run-csharp.sh create mode 100755 tests/scripts/run-node.sh 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() }); + } + + [Route("/pages/home")] + sealed class SampleHomePage : FusionBaseTemplate + { + static SampleHomePage() + { + Template = "home/index.html"; + } + + public override Dictionary Context() => new() + { + ["title"] = "Home", + }; + } +} diff --git a/tests/node/helpers/load-fusion.js b/tests/node/helpers/load-fusion.js new file mode 100644 index 0000000..aca5a6b --- /dev/null +++ b/tests/node/helpers/load-fusion.js @@ -0,0 +1,4 @@ +const path = require('path') + +/** Resolve the local fusion-framework package from the monorepo. */ +module.exports = require(path.resolve(__dirname, '../../../crates/fusion-node')) diff --git a/tests/node/unit/bindings.test.js b/tests/node/unit/bindings.test.js new file mode 100644 index 0000000..fbdf3e9 --- /dev/null +++ b/tests/node/unit/bindings.test.js @@ -0,0 +1,146 @@ +const { describe, it, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const fusion = require('../helpers/load-fusion') +const { + FusionBaseApi, + FusionBaseTemplate, + route, + httpGet, + clearRouteRegistry, + openapiSpec, + routeVersions, + hasUnversionedRoutes, + runMiddlewareChain, + bearerJwt, + cors, + requireRoles, + frameworkHeaders, + resolveRoutePath, + apiResourceName, +} = fusion + +function handler(request) { + return { status: 200, body: { state: request.state || {} } } +} + +describe('routing helpers', () => { + it('resolveRoutePath expands [module]', () => { + assert.equal(resolveRoutePath('/api/[module]', { name: 'ProductModule' }), '/api/product') + }) + + it('apiResourceName strips Module suffix', () => { + assert.equal(apiResourceName({ name: 'ProductModule' }), 'product') + }) +}) + +describe('http routes', () => { + beforeEach(() => clearRouteRegistry()) + + it('registers custom http_get with [action] token', () => { + class UserModule extends FusionBaseApi { + UserAction() { + return { ok: true } + } + } + httpGet('test/[action]')(UserModule.prototype.UserAction) + route('/api/[module]')(UserModule) + + const spec = openapiSpec() + assert.ok(spec.paths['/api/user/test/user']) + assert.ok(spec.paths['/api/user/test/user'].get) + assert.equal(spec.paths['/api/user/test/user'].get.operationId, 'UserModule_UserAction') + }) + + it('splits openapi specs by version', () => { + class V1Hello extends FusionBaseApi { + get() { + return { v: 1 } + } + } + class V2Hello extends FusionBaseApi { + get() { + return { v: 2 } + } + } + class Health extends FusionBaseApi { + get() { + return { ok: true } + } + } + + route('/hello', { version: 'v1' })(V1Hello) + route('/hello', { version: 'v2' })(V2Hello) + route('/health')(Health) + + assert.deepEqual(routeVersions(), ['v1', 'v2']) + assert.equal(hasUnversionedRoutes(), true) + + const v1 = openapiSpec('v1') + assert.ok(v1.paths['/v1/hello']) + assert.equal(v1.paths['/v2/hello'], undefined) + assert.equal(v1.paths['/health'], undefined) + }) + + it('omits template routes from openapi', () => { + class HomePage extends FusionBaseTemplate { + static template = 'home/index.html' + context() { + return { title: 'Home' } + } + } + class ItemsApi extends FusionBaseApi { + get() { + return { items: [] } + } + } + + route('/pages/home')(HomePage) + route('/api/items', { version: 'v1', tags: ['items'] })(ItemsApi) + + const combined = openapiSpec() + assert.equal(combined.paths['/pages/home'], undefined) + + const v1 = openapiSpec('v1') + assert.equal(v1.paths['/pages/home'], undefined) + assert.ok(v1.paths['/v1/api/items']) + }) +}) + +describe('middleware', () => { + beforeEach(() => clearRouteRegistry()) + + it('bearerJwt stores payload in state', async () => { + const token = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0.' + const request = { headers: { Authorization: `Bearer ${token}` } } + const result = await runMiddlewareChain(request, [bearerJwt()], handler) + assert.equal(result.status, 200) + assert.equal(result.body.state.jwt.sub, '1') + }) + + it('requireRoles returns 403 when role missing', async () => { + const request = { headers: {}, state: { jwt: { roles: ['user'] } } } + const result = await runMiddlewareChain(request, [requireRoles('admin')], handler) + assert.equal(result.status, 403) + }) + + it('cors short-circuits OPTIONS preflight', async () => { + const request = { + method: 'OPTIONS', + path: '/api', + headers: { Origin: 'https://example.com' }, + } + const result = await runMiddlewareChain(request, [cors()], handler) + assert.equal(result.status, 204) + const headers = Object.fromEntries( + Object.entries(result.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) + ) + assert.ok(headers['access-control-allow-origin']) + }) + + it('frameworkHeaders merges identity headers', async () => { + const request = { path: '/', headers: {}, method: 'GET' } + const result = await runMiddlewareChain(request, [frameworkHeaders()], handler) + assert.ok(result.headers['x-powered-by'] || result.headers['X-Powered-By']) + }) +}) diff --git a/tests/scripts/run-all.sh b/tests/scripts/run-all.sh index 6029a91..a33b482 100755 --- a/tests/scripts/run-all.sh +++ b/tests/scripts/run-all.sh @@ -11,9 +11,16 @@ cargo test -p fusion-core echo "==> Node syntax" node --check crates/fusion-node/index.js -echo "==> C# build" -dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj --no-restore 2>/dev/null \ - || dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj +echo "==> Node unit tests" +if ls crates/fusion-node/*.node >/dev/null 2>&1; then + ./tests/scripts/run-node.sh +else + echo " skip: build addon with (cd crates/fusion-node && npm run build:debug)" +fi + +echo "==> C# tests" +cargo build -p fusion-ffi --release -q +./tests/scripts/run-csharp.sh -q echo "==> Python (pytest)" if ! python3 -c "import fusion_framework" 2>/dev/null; then diff --git a/tests/scripts/run-csharp.sh b/tests/scripts/run-csharp.sh new file mode 100755 index 0000000..44f9709 --- /dev/null +++ b/tests/scripts/run-csharp.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +echo "Building fusion-ffi (required for C# tests)..." +cargo build -p fusion-ffi --release + +dotnet test tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj -c Release "$@" diff --git a/tests/scripts/run-node.sh b/tests/scripts/run-node.sh new file mode 100755 index 0000000..acf099c --- /dev/null +++ b/tests/scripts/run-node.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT/crates/fusion-node" + +if ! ls ./*.node >/dev/null 2>&1; then + echo "Native addon missing — run: npm run build:debug (in crates/fusion-node)" >&2 + exit 1 +fi + +node --test "$ROOT/tests/node/unit/"*.test.js "$@"