diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c684b82 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,csproj,props,targets}] +indent_style = space +indent_size = 4 + +[*.cs] +dotnet_sort_system_directives_first = true +csharp_new_line_before_open_brace = all +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f47098f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,34 @@ +name: quality-gate + +on: + push: + branches: + - main + - codex/** + pull_request: + workflow_dispatch: + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.301 + + - name: Restore locked dependencies + run: dotnet restore Rendezvous.slnx --locked-mode + + - name: Verify formatting and analyzers + run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore + + - name: Build + run: dotnet build Rendezvous.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test Rendezvous.slnx --configuration Release --no-build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c8fa88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +**/bin/ +**/obj/ +TestResults/ +.idea/ +.vs/ +*.suo +*.user +*.userosscache diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e1a705e --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,15 @@ + + + latest-recommended + true + true + true + true + enable + latest + enable + true + true + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..6885e54 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,12 @@ + + + true + true + + + + + + + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..4e800bf --- /dev/null +++ b/NuGet.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md index b83b61f..07a013d 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,21 @@ Rendezvous is currently in its initial design and bootstrap stage. The first imp The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and threat model are indexed in [the architecture documentation](docs/architecture/README.md). + +## Development + +The repository pins .NET SDK 10.0.301. From a clean clone, run the same gates as +CI from the repository root: + +```bash +dotnet restore Rendezvous.slnx --locked-mode +dotnet format Rendezvous.slnx --verify-no-changes --no-restore +dotnet build Rendezvous.slnx --configuration Release --no-restore +dotnet test Rendezvous.slnx --configuration Release --no-build +``` + +Run the bootstrap server with +`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds +the configured UDP mediator port; both stop through normal host cancellation. +The project dependency rules and supported runtime choices are documented in +[project and dependency boundaries](docs/architecture/project-boundaries.md). diff --git a/Rendezvous.slnx b/Rendezvous.slnx new file mode 100644 index 0000000..b375c75 --- /dev/null +++ b/Rendezvous.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/architecture/project-boundaries.md b/docs/architecture/project-boundaries.md new file mode 100644 index 0000000..a518eef --- /dev/null +++ b/docs/architecture/project-boundaries.md @@ -0,0 +1,45 @@ +# Project and dependency boundaries + +Tracking: #3 + +```text +FinalFactory.Rendezvous.Contracts <- FinalFactory.Rendezvous.Client + ^ ^ + | | +FinalFactory.Rendezvous.Server FinalFactory.Rendezvous.TestClient +``` + +- `Contracts` targets `netstandard2.1` and contains only versioned, + transport-neutral IDs and wire contracts. It cannot reference Server, + LiteNetLib, or Godot. +- `Client` targets `netstandard2.1`, references Contracts and the pinned + LiteNetLib package, and contains no Godot or Server dependency. +- `Server` targets .NET 10 LTS, references Contracts and LiteNetLib, and owns + HTTP hosting, UDP mediation, application policy, and ephemeral state. +- `TestClient` targets .NET 8, references only the public Client/Contracts seams + and LiteNetLib, and must never reach into Server internals. +- `Tests` target .NET 10 and may reference every project solely to verify public + behavior and architecture boundaries. + +The dependency-boundary tests inspect compiled assembly references. A forbidden +engine, transport, or server dependency therefore fails the normal test gate. + +## Supported toolchain + +- Build SDK: .NET SDK 10.0.301, pinned by `global.json`. +- Server runtime: .NET 10 LTS. +- Client/contracts compatibility target: .NET Standard 2.1, consumable by the + .NET 8-or-later runtime used by current Godot 4 C# projects. +- TestClient runtime: .NET 8. +- LiteNetLib: 2.1.4, pinned centrally and restored from the lock files. + +The repository uses central package versions, per-project lock files, +deterministic compilation, nullable reference types, warnings as errors, current +.NET analyzers, and formatting verification. CI restores in locked mode so a +package graph change must be deliberate and committed. + +Primary compatibility references: + +- [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy) +- [Godot stable C# prerequisites](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_basics.html) +- [LiteNetLib 2.1.4 on NuGet](https://www.nuget.org/packages/LiteNetLib/2.1.4) diff --git a/global.json b/global.json new file mode 100644 index 0000000..a5f940e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.301", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj new file mode 100644 index 0000000..b1d411c --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + FinalFactory.Rendezvous.Client + FinalFactory.Rendezvous.Client + true + FinalFactory.Rendezvous.Client + Godot-independent client SDK for Final Factory Rendezvous. + + + + + + diff --git a/src/FinalFactory.Rendezvous.Client/packages.lock.json b/src/FinalFactory.Rendezvous.Client/packages.lock.json new file mode 100644 index 0000000..fc4b597 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/packages.lock.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.1": { + "LiteNetLib": { + "type": "Direct", + "requested": "[2.1.4, )", + "resolved": "2.1.4", + "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" + }, + "finalfactory.rendezvous.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj new file mode 100644 index 0000000..1384e57 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj @@ -0,0 +1,10 @@ + + + netstandard2.1 + FinalFactory.Rendezvous.Contracts + FinalFactory.Rendezvous.Contracts + true + FinalFactory.Rendezvous.Contracts + Versioned transport-neutral contracts for Final Factory Rendezvous. + + diff --git a/src/FinalFactory.Rendezvous.Contracts/packages.lock.json b/src/FinalFactory.Rendezvous.Contracts/packages.lock.json new file mode 100644 index 0000000..a2617b3 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Contracts/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.1": {} + } +} \ No newline at end of file diff --git a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj new file mode 100644 index 0000000..eff1ba5 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj @@ -0,0 +1,12 @@ + + + net10.0 + FinalFactory.Rendezvous.Server + FinalFactory.Rendezvous.Server + false + + + + + + diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs new file mode 100644 index 0000000..e91eec1 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -0,0 +1,31 @@ +using System.Net; +using FinalFactory.Rendezvous.Server.Transport; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddOptions() + .BindConfiguration(UdpMediatorOptions.SectionName) + .ValidateDataAnnotations() + .Validate( + options => IPAddress.TryParse(options.ListenAddress, out _), + $"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.") + .ValidateOnStart(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(static services => services.GetRequiredService()); + +WebApplication app = builder.Build(); + +app.MapGet("/health/live", static () => Results.Ok(new { status = "live" })); +app.MapGet( + "/health/ready", + static (UdpMediatorService mediator) => mediator.LocalEndpoint is null + ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable) + : Results.Ok(new { status = "ready" })); + +await app.RunAsync(); + +/// +/// Entry point marker used by integration-test hosts. +/// +public partial class Program; diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorOptions.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorOptions.cs new file mode 100644 index 0000000..2e8b389 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorOptions.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace FinalFactory.Rendezvous.Server.Transport; + +/// +/// Configures the UDP endpoint reserved for the Rendezvous mediator. +/// +public sealed class UdpMediatorOptions +{ + /// + /// Configuration section containing UDP mediator settings. + /// + public const string SectionName = "Rendezvous:Udp"; + + /// + /// Gets or sets the numeric IP address to bind. + /// + [Required] + public string ListenAddress { get; set; } = "0.0.0.0"; + + /// + /// Gets or sets the UDP port. Zero requests an ephemeral port for tests. + /// + [Range(0, 65_535)] + public int Port { get; set; } = 9050; +} diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs new file mode 100644 index 0000000..11232df --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Options; + +namespace FinalFactory.Rendezvous.Server.Transport; + +/// +/// Owns the cancellable UDP socket used by the future NAT mediator. +/// +public sealed partial class UdpMediatorService : BackgroundService +{ + private readonly ILogger _logger; + private readonly UdpMediatorOptions _options; + private UdpClient? _udpClient; + + /// + /// Initializes a new UDP mediator service. + /// + public UdpMediatorService( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + /// + /// Gets the bound endpoint after startup completes. + /// + public IPEndPoint? LocalEndpoint { get; private set; } + + /// + public override Task StartAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_udpClient is not null) + { + throw new InvalidOperationException("The UDP mediator is already running."); + } + + IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress); + UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port)); + _udpClient = udpClient; + IPEndPoint localEndpoint = + (IPEndPoint?)udpClient.Client.LocalEndPoint + ?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint."); + LocalEndpoint = localEndpoint; + + LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port); + + return base.StartAsync(cancellationToken); + } + + /// + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken).ConfigureAwait(false); + _udpClient?.Dispose(); + _udpClient = null; + LocalEndpoint = null; + LogMediatorStopped(_logger); + } + + /// + public override void Dispose() + { + _udpClient?.Dispose(); + _udpClient = null; + LocalEndpoint = null; + base.Dispose(); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + UdpClient udpClient = _udpClient + ?? throw new InvalidOperationException("The UDP mediator socket was not initialized."); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + _ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false); + // Bootstrap deliberately emits no UDP response. Protocol handling lands in #11. + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Expected during normal shutdown. + } + catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested) + { + // Disposing the socket is the fallback that releases a blocked receive. + } + finally + { + LocalEndpoint = null; + } + } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "UDP mediator listening on {ListenAddress}:{ListenPort}")] + private static partial void LogMediatorListening( + ILogger logger, + IPAddress listenAddress, + int listenPort); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "UDP mediator stopped")] + private static partial void LogMediatorStopped(ILogger logger); +} diff --git a/src/FinalFactory.Rendezvous.Server/appsettings.json b/src/FinalFactory.Rendezvous.Server/appsettings.json new file mode 100644 index 0000000..966bfca --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/appsettings.json @@ -0,0 +1,15 @@ +{ + "Rendezvous": { + "Udp": { + "ListenAddress": "0.0.0.0", + "Port": 9050 + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/FinalFactory.Rendezvous.Server/packages.lock.json b/src/FinalFactory.Rendezvous.Server/packages.lock.json new file mode 100644 index 0000000..8ca1330 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/packages.lock.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "LiteNetLib": { + "type": "Direct", + "requested": "[2.1.4, )", + "resolved": "2.1.4", + "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" + }, + "finalfactory.rendezvous.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj b/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj new file mode 100644 index 0000000..804e4a3 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + FinalFactory.Rendezvous.TestClient + FinalFactory.Rendezvous.TestClient + false + + + + + + + diff --git a/src/FinalFactory.Rendezvous.TestClient/Program.cs b/src/FinalFactory.Rendezvous.TestClient/Program.cs new file mode 100644 index 0000000..a0a5ba0 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/Program.cs @@ -0,0 +1,16 @@ +namespace FinalFactory.Rendezvous.TestClient; + +/// +/// Bootstrap entry point for the public-SDK-only diagnostic client. +/// +public static class Program +{ + /// + /// Runs the bootstrap diagnostic. + /// + public static int Main() + { + Console.WriteLine("Rendezvous TestClient bootstrap is ready."); + return 0; + } +} diff --git a/src/FinalFactory.Rendezvous.TestClient/packages.lock.json b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json new file mode 100644 index 0000000..fda156e --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json @@ -0,0 +1,23 @@ +{ + "version": 2, + "dependencies": { + "net8.0": { + "LiteNetLib": { + "type": "Direct", + "requested": "[2.1.4, )", + "resolved": "2.1.4", + "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" + }, + "finalfactory.rendezvous.client": { + "type": "Project", + "dependencies": { + "FinalFactory.Rendezvous.Contracts": "[1.0.0, )", + "LiteNetLib": "[2.1.4, )" + } + }, + "finalfactory.rendezvous.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs b/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs new file mode 100644 index 0000000..d9b627f --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs @@ -0,0 +1,113 @@ +using System.Reflection; +using System.Xml.Linq; + +namespace FinalFactory.Rendezvous.Tests.Architecture; + +public sealed class DependencyBoundaryTests +{ + [Fact] + public void ContractsAreTransportAndEngineIndependent() + { + string[] references = GetReferences("FinalFactory.Rendezvous.Contracts"); + + Assert.DoesNotContain(references, IsGodotAssembly); + Assert.DoesNotContain(references, IsServerAssembly); + Assert.DoesNotContain(references, IsLiteNetLibAssembly); + } + + [Fact] + public void ClientIsGodotAndServerIndependent() + { + string[] references = GetReferences("FinalFactory.Rendezvous.Client"); + + Assert.DoesNotContain(references, IsGodotAssembly); + Assert.DoesNotContain(references, IsServerAssembly); + } + + [Fact] + public void TestClientUsesOnlyPublicRendezvousDependencies() + { + string[] references = GetReferences("FinalFactory.Rendezvous.TestClient"); + + Assert.DoesNotContain(references, IsGodotAssembly); + Assert.DoesNotContain(references, IsServerAssembly); + } + + [Fact] + public void DeclaredDependencyGraphMatchesTheArchitecture() + { + AssertDeclaredDependencies( + "src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj", + [], + []); + AssertDeclaredDependencies( + "src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj", + ["FinalFactory.Rendezvous.Contracts"], + ["LiteNetLib"]); + AssertDeclaredDependencies( + "src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj", + ["FinalFactory.Rendezvous.Contracts"], + ["LiteNetLib"]); + AssertDeclaredDependencies( + "src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj", + ["FinalFactory.Rendezvous.Client", "FinalFactory.Rendezvous.Contracts"], + ["LiteNetLib"]); + } + + private static void AssertDeclaredDependencies( + string projectPath, + string[] expectedProjects, + string[] expectedPackages) + { + XDocument project = XDocument.Load(Path.Combine(FindRepositoryRoot(), projectPath)); + string[] projects = project + .Descendants("ProjectReference") + .Select(static element => + Path.GetFileNameWithoutExtension(element.Attribute("Include")?.Value) ?? string.Empty) + .Order(StringComparer.Ordinal) + .ToArray(); + string[] packages = project + .Descendants("PackageReference") + .Select(static element => element.Attribute("Include")?.Value ?? string.Empty) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(expectedProjects.Order(StringComparer.Ordinal), projects); + Assert.Equal(expectedPackages.Order(StringComparer.Ordinal), packages); + } + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the Rendezvous repository root."); + } + + private static string[] GetReferences(string assemblyName) => Assembly + .Load(assemblyName) + .GetReferencedAssemblies() + .Select(static reference => reference.Name ?? string.Empty) + .ToArray(); + + private static bool IsGodotAssembly(string assemblyName) => + assemblyName.StartsWith("Godot", StringComparison.OrdinalIgnoreCase); + + private static bool IsLiteNetLibAssembly(string assemblyName) => + string.Equals(assemblyName, "LiteNetLib", StringComparison.Ordinal); + + private static bool IsServerAssembly(string assemblyName) => + string.Equals( + assemblyName, + "FinalFactory.Rendezvous.Server", + StringComparison.Ordinal); +} diff --git a/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj b/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj new file mode 100644 index 0000000..a6e6d97 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + FinalFactory.Rendezvous.Tests + FinalFactory.Rendezvous.Tests + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/FinalFactory.Rendezvous.Tests/GlobalUsings.cs b/tests/FinalFactory.Rendezvous.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs new file mode 100644 index 0000000..87030f8 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs @@ -0,0 +1,34 @@ +using System.Net; +using FinalFactory.Rendezvous.Server.Transport; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace FinalFactory.Rendezvous.Tests.Server; + +public sealed class UdpMediatorServiceTests +{ + [Fact] + public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly() + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); + UdpMediatorOptions options = new() + { + ListenAddress = IPAddress.Loopback.ToString(), + Port = 0, + }; + using UdpMediatorService service = new( + Options.Create(options), + NullLogger.Instance); + + await service.StartAsync(timeout.Token); + + IPEndPoint? boundEndpoint = service.LocalEndpoint; + Assert.NotNull(boundEndpoint); + Assert.Equal(IPAddress.Loopback, boundEndpoint.Address); + Assert.InRange(boundEndpoint.Port, 1, 65_535); + + await service.StopAsync(timeout.Token); + + Assert.Null(service.LocalEndpoint); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/packages.lock.json b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json new file mode 100644 index 0000000..bbf07c8 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json @@ -0,0 +1,129 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.4.0, )", + "resolved": "18.4.0", + "contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.4.0", + "Microsoft.TestPlatform.TestHost": "18.4.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.4.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "finalfactory.rendezvous.client": { + "type": "Project", + "dependencies": { + "FinalFactory.Rendezvous.Contracts": "[1.0.0, )", + "LiteNetLib": "[2.1.4, )" + } + }, + "finalfactory.rendezvous.contracts": { + "type": "Project" + }, + "finalfactory.rendezvous.server": { + "type": "Project", + "dependencies": { + "FinalFactory.Rendezvous.Contracts": "[1.0.0, )", + "LiteNetLib": "[2.1.4, )" + } + }, + "finalfactory.rendezvous.testclient": { + "type": "Project", + "dependencies": { + "FinalFactory.Rendezvous.Client": "[1.0.0, )", + "FinalFactory.Rendezvous.Contracts": "[1.0.0, )", + "LiteNetLib": "[2.1.4, )" + } + }, + "LiteNetLib": { + "type": "CentralTransitive", + "requested": "[2.1.4, )", + "resolved": "2.1.4", + "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" + } + } + } +} \ No newline at end of file