diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 648ea8f..277b5c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,7 +46,8 @@ jobs: if-no-files-found: error - name: Test - run: dotnet test Yllibed.HttpServer.slnx /p:Configuration=Release --no-build + run: dotnet test Yllibed.HttpServer.slnx /p:Configuration=Release --no-build --collect "XPlat Code Coverage" + publish: if: startsWith(github.ref, 'refs/heads/master') diff --git a/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseMoreFixture.cs b/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseMoreFixture.cs new file mode 100644 index 0000000..dfc3d97 --- /dev/null +++ b/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseMoreFixture.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Yllibed.HttpServer.Json.Tests; + +[TestClass] +public class JsonHandlerBaseMoreFixture : FixtureBase +{ + private sealed class ThrowingHandler : JsonHandlerBase + { + public ThrowingHandler(string method, string path) : base(method, path) { } + + protected override Task<(object result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary queryParameters) + { + throw new InvalidOperationException("boom"); + } + } + + private sealed class EchoHandler : JsonHandlerBase + { + public EchoHandler(string method, string path) : base(method, path) { } + + protected override Task<(object result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary queryParameters) + { + return Task.FromResult<(object, ushort)>((new { ok = true }, 200)); + } + } + + [TestMethod] + public async Task JsonHandler_WhenProcessThrows_ShouldReturn500WithPlainText() + { + using var server = new Server(); + server.RegisterHandler(new ThrowingHandler("GET", "/err")); + var (uri4, _) = server.Start(); + var requestUri = new Uri(uri4, "err"); + + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/plain"); + var content = await response.Content.ReadAsStringAsync(CT); + content.Should().Contain("Error processing request"); + } + + [TestMethod] + public async Task JsonHandler_WrongMethod_ShouldNotHandle_AndServerReturns404() + { + using var server = new Server(); + server.RegisterHandler(new EchoHandler("POST", "/j")); + var (uri4, _) = server.Start(); + var requestUri = new Uri(uri4, "j"); + + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/Yllibed.HttpServer.Tests/E2EApp/E2EApp.csproj b/Yllibed.HttpServer.Tests/E2EApp/E2EApp.csproj new file mode 100644 index 0000000..fd1edef --- /dev/null +++ b/Yllibed.HttpServer.Tests/E2EApp/E2EApp.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/Yllibed.HttpServer.Tests/E2EApp/Program.cs b/Yllibed.HttpServer.Tests/E2EApp/Program.cs new file mode 100644 index 0000000..e8893f1 --- /dev/null +++ b/Yllibed.HttpServer.Tests/E2EApp/Program.cs @@ -0,0 +1,44 @@ +using Yllibed.HttpServer; +using Yllibed.HttpServer.Handlers; +using Yllibed.HttpServer.Sse; + +// E2E = End-to-End. This small app is started by the E2E tests +// to spin up a real HTTP server and verify, via a real client, that the /ping endpoint +// responds correctly. Here we deliberately pick a dynamic port to showcase dynamic port discovery. +var server = new Server(); // dynamic port +server.RegisterHandler(new StaticHandler("ping", "text/plain", "pong")); + +// Register an SSE endpoint under /sse/js that emits a single event +var sseRoute = new RelativePathHandler("sse"); +sseRoute.RegisterHandler(new E2EApp.JsSseHandler()); +server.RegisterHandler(sseRoute); + +var (uri4, uri6) = server.Start(); + +// Expose the dynamically selected port in a machine-readable way for the Node E2E runner +Console.WriteLine("PORT={0}", uri4.Port); +Console.WriteLine($"E2EApp started on {uri4} and {uri6}"); +Console.WriteLine("READY"); + +// Keep the app alive until killed +await Task.Delay(Timeout.InfiniteTimeSpan); + +// Local SSE handler used only by the E2E app +namespace E2EApp +{ + internal sealed class JsSseHandler : SseHandler + { + protected override bool ShouldHandle(IHttpServerRequest request, string relativePath) + => base.ShouldHandle(request, relativePath) && relativePath == "/js"; + + protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath) + => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero }; + + protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct) + { + // Emit a simple event with a fixed id/name and JSON payload + // Emit compact JSON manually to avoid extra project references + await sse.SendEventAsync("{\"A\":1,\"B\":\"x\"}", eventName: "obj", id: "e2e-1", ct: ct).ConfigureAwait(false); + } + } +} diff --git a/Yllibed.HttpServer.Tests/E2E_NodeFixture.cs b/Yllibed.HttpServer.Tests/E2E_NodeFixture.cs new file mode 100644 index 0000000..a281135 --- /dev/null +++ b/Yllibed.HttpServer.Tests/E2E_NodeFixture.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; + +namespace Yllibed.HttpServer.Tests; + +[TestClass] +public sealed class E2E_NodeFixture : FixtureBase +{ + [TestMethod] + public void E2E_NodeScript_ShouldSucceed() + { + // Verify that Node.js is available (otherwise mark the test as Inconclusive) + if (!IsCommandAvailable("node")) + { + Assert.Inconclusive("Node.js (node) is not available on PATH. Install Node.js to run this E2E test."); + } + + var solutionRoot = GetSolutionRoot(); + // Script now located under the test project folder + var scriptPath = Path.Combine(solutionRoot, "Yllibed.HttpServer.Tests", "e2e", "run-e2e.js"); + if (!File.Exists(scriptPath)) + { + // Fallback: when tests run from within the test project root already + scriptPath = Path.Combine(solutionRoot, "e2e", "run-e2e.js"); + } + File.Exists(scriptPath).Should().BeTrue("E2E script must exist at {0}", scriptPath); + + var psi = new ProcessStartInfo + { + FileName = "node", + Arguments = scriptPath, + WorkingDirectory = Path.GetDirectoryName(scriptPath)!, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; + var stdout = new System.Text.StringBuilder(); + var stderr = new System.Text.StringBuilder(); + proc.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); }; + + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + // Give a generous timeout (same order as the script, which times out after ~10s) + var exited = proc.WaitForExit(30000); + if (!exited) + { + try { proc.Kill(entireProcessTree: true); } catch (Exception) { /* ignore */ } + Assert.Fail("E2E Node script did not exit within timeout.\nSTDOUT:\n{0}\nSTDERR:\n{1}", stdout.ToString(), stderr.ToString()); + } + + // Ensure we read all remaining output + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + Assert.Fail("E2E Node script failed with exit code {0}.\nSTDOUT:\n{1}\nSTDERR:\n{2}", proc.ExitCode, stdout.ToString(), stderr.ToString()); + } + } + + private static bool IsCommandAvailable(string command) + { + try + { + var psi = new ProcessStartInfo + { + FileName = command, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + if (p is null) return false; + p.WaitForExit(5000); + return p.ExitCode == 0; + } + catch + { + return false; + } + } + + private static string GetSolutionRoot() + { + // Heuristic: tests usually run with a WorkingDirectory under the test project's bin folder. + // Walk up until we find the solution file. + var dir = new DirectoryInfo(Environment.CurrentDirectory); + for (int i = 0; i < 10 && dir is not null; i++, dir = dir.Parent!) + { + if (File.Exists(Path.Combine(dir.FullName, "Yllibed.HttpServer.slnx"))) + { + return dir.FullName; + } + } + // Fallback: base directory if available + return AppContext.BaseDirectory; // best effort + } +} diff --git a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj index e18dcf8..8982351 100644 --- a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj +++ b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj @@ -22,4 +22,10 @@ + + + + + + diff --git a/Yllibed.HttpServer.Tests/e2e/run-e2e.js b/Yllibed.HttpServer.Tests/e2e/run-e2e.js new file mode 100644 index 0000000..863c95d --- /dev/null +++ b/Yllibed.HttpServer.Tests/e2e/run-e2e.js @@ -0,0 +1,140 @@ +/* + E2E (End-to-End) = tests that validate the whole system from the + user/consumer point of view: we start a real server app, send a real + HTTP request, and assert the response. + + Here, this Node script acts as the E2E runner: + - it starts the .NET E2EApp (dotnet run) that hosts our HTTP server; + - the app prints a machine-readable line PORT= followed by READY; + - the script waits for READY and captures the port; + - it performs a GET /ping to http://127.0.0.1:/ping and verifies status/content/body; + - it connects to an SSE endpoint /sse/js and validates the first event (id, event, data); + - it then terminates the spawned process cleanly. + + These tests differ from unit/integration tests because they exercise the + entire chain: client → network → server → handler → response. +*/ +// Simple Node E2E script to validate the server works end-to-end +const { spawn } = require('child_process'); +const http = require('http'); +const path = require('path'); + +function waitForReady(proc, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + let ready = false; + let port = null; + const timer = setTimeout(() => { + if (!ready) reject(new Error('Timeout waiting for READY')); + }, timeoutMs); + + proc.stdout.on('data', (data) => { + const txt = data.toString(); + process.stdout.write(txt); + // In case multiple lines are delivered at once, split and scan each + for (const line of txt.split(/\r?\n/)) { + if (!line) continue; + if (line.startsWith('PORT=')) { + const v = parseInt(line.substring('PORT='.length), 10); + if (!Number.isNaN(v) && v > 0) port = v; + } + if (line.includes('READY')) { + ready = true; + clearTimeout(timer); + if (!port) return reject(new Error('READY received but no PORT= captured')); + return resolve(port); + } + } + }); + proc.stderr.on('data', (data) => process.stderr.write(data.toString())); + proc.on('exit', (code) => { + if (!ready) reject(new Error(`E2E app exited before ready. Code: ${code}`)); + }); + }); +} + +function httpGet(url) { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers })); + }); + req.on('error', reject); + }); +} + +(async () => { + // Compute project path relative to this script location + const scriptDir = __dirname; + const testProjectRoot = path.resolve(scriptDir, '..'); + const e2eAppProj = path.resolve(testProjectRoot, 'E2EApp'); + + const app = spawn('dotnet', ['run', '--project', e2eAppProj, '-c', 'Release'], { cwd: testProjectRoot, env: process.env }); + try { + const port = await waitForReady(app); + + const res = await httpGet(`http://127.0.0.1:${port}/ping`); + if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`); + if ((res.headers['content-type']||'').split(';')[0] !== 'text/plain') throw new Error(`Unexpected content-type: ${res.headers['content-type']}`); + if (res.body !== 'pong') throw new Error(`Unexpected body: ${res.body}`); + + // SSE: connect and validate first event + await new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}/sse/js`, (res2) => { + const ct = (res2.headers['content-type']||'').split(';')[0]; + if (res2.statusCode !== 200) return reject(new Error(`SSE unexpected status: ${res2.statusCode}`)); + if (ct !== 'text/event-stream') return reject(new Error(`SSE unexpected content-type: ${res2.headers['content-type']}`)); + res2.setEncoding('utf8'); + let buffer = ''; + let got = false; + const timer = setTimeout(() => { + if (!got) reject(new Error('Timeout waiting for first SSE event')); + }, 5000); + res2.on('data', (chunk) => { + buffer += chunk; + // Parse by lines, server may send multiple events or heartbeats + let idx; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + if (!line) { + // blank line terminates an event; keep collecting in outer scope if needed + continue; + } + // Collect fields + // We expect at least: id:e2e-1, event:obj, data:{"A":1,"B":"x"} + if (line.startsWith('id:')) { + const id = line.substring(3).trim(); + if (id !== 'e2e-1') return reject(new Error(`SSE unexpected id: ${id}`)); + } else if (line.startsWith('event:')) { + const ev = line.substring(6).trim(); + if (ev !== 'obj') return reject(new Error(`SSE unexpected event: ${ev}`)); + } else if (line.startsWith('data:')) { + const data = line.substring(5).trim(); + if (data !== '{"A":1,"B":"x"}') return reject(new Error(`SSE unexpected data: ${data}`)); + got = true; + clearTimeout(timer); + resolve(); + req.destroy(); + return; + } + } + }); + res2.on('error', reject); + }); + req.on('error', reject); + }); + + console.log('E2E OK'); + } catch (e) { + console.error('E2E FAILED:', e); + process.exitCode = 1; + } finally { + if (process.platform === 'win32') { + spawn('taskkill', ['/PID', String(app.pid), '/T', '/F']); + } else { + app.kill('SIGKILL'); + } + } +})();