tests/Networking/CommandDispatchOrchestratorTests.cs

Unit tests for CommandDispatchOrchestrator behavior. Exercises many dispatch scenarios using a FakeDispatchHost to verify admission, charging, resolution (initial and stable), execution, completion, error mapping and command cost table constants.

#nullable enable

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Networking;

namespace Hexagon.V2.Tests.Networking;

[TestClass]
public sealed class CommandDispatchOrchestratorTests
{
	[TestMethod]
	public void UnauthenticatedCallerIsRejectedBeforeAnyChargeOrResolution()
	{
		var host = new FakeDispatchHost { Authenticated = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.IsEmpty( host.Calls, "Unauthenticated traffic must never reach the per-connection bucket." );
		Assert.HasCount( 1, host.Sent );
		Assert.AreEqual( "caller", host.Sent[0].Target );
		Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ChargeHappensBeforeResolutionAndCarriesTheDeclaredCost()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 3, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( "charge:3", host.Calls[0], "The caller is charged before its scope is inspected." );
		Assert.AreEqual( "resolve:initial", host.Calls[1] );
	}

	[TestMethod]
	public void RateLimitedAdmissionCarriesRetryAfterAndSkipsResolution()
	{
		var host = new FakeDispatchHost
		{
			Admission = CommandAdmissionResult.Reject(
				CommandAdmissionFailure.RateLimited, TimeSpan.FromSeconds( 2 ) )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 4, static () => new RequestCharacterListCommand() );

		CollectionAssert.AreEqual( new[] { "charge:4" }, host.Calls );
		Assert.HasCount( 1, host.Sent );
		Assert.AreEqual( ErrorCode.RateLimited, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void DuplicateRequestIdsConflict()
	{
		var host = new FakeDispatchHost
		{
			Admission = CommandAdmissionResult.Reject( CommandAdmissionFailure.Duplicate )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.Conflict, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ResolutionFailureIsForwardedAndTheAcceptedChargeIsClosed()
	{
		var host = new FakeDispatchHost
		{
			Resolution = OperationResult<string>.Failure( ErrorCode.Unauthorized, "scope mismatch" )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.AreEqual(
			new[] { "charge:1", "resolve:initial", "finish-rejected" },
			host.Calls );
		Assert.AreEqual( "scope mismatch", host.Sent[0].Result.Error!.Message );
	}

	[TestMethod]
	public void EmptyRequestIdCompletesWithInvalidArgument()
	{
		var host = new FakeDispatchHost();
		// Wire deserialization can materialize a default header; the constructor itself
		// rejects empty request ids, so the guard is only reachable through default structs.
		var header = default( ClientCommandHeader );

		CommandDispatchOrchestrator.Dispatch( host, header, 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.Contains( host.Calls, "complete" );
		Assert.HasCount( 1, host.Sent );
		Assert.AreEqual( "actor", host.Sent[0].Target );
		Assert.AreEqual( ErrorCode.InvalidArgument, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ThrowingCommandFactoryFailsClosedWithoutStartingAHostOperation()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1,
			static () => throw new FormatException( "bad wire payload" ) );

		Assert.HasCount( 1, host.MalformedPayloads );
		CollectionAssert.DoesNotContain( host.Calls, "host-operation" );
		Assert.AreEqual( ErrorCode.InvalidArgument, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void OversizedPayloadIsRejectedBeforeStableResolutionAndExecution()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
			static () => new SendChatCommand( "ic", new string( 'a', 1_000_000 ) ) );

		CollectionAssert.DoesNotContain( host.Calls, "resolve:stable" );
		CollectionAssert.DoesNotContain( host.Calls, "host-operation" );
		Assert.HasCount( 1, host.Sent );
		Assert.IsFalse( host.Sent[0].Result.Succeeded );
	}

	[TestMethod]
	public void StableCharacterCommandsAreReResolvedAfterPayloadValidation()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
			static () => new SendChatCommand( "ic", "hello" ) );

		CollectionAssert.AreEqual(
			new[]
			{
				"charge:2", "resolve:initial", "resolve:stable",
				"host-operation", "lease-check", "execute", "complete"
			},
			host.Calls );
		Assert.AreEqual( "stable-actor", host.Sent[0].Target,
			"Execution and completion must use the re-resolved stable actor." );
	}

	[TestMethod]
	public void StableResolutionFailureCompletesWithoutExecuting()
	{
		var host = new FakeDispatchHost
		{
			StableResolution = OperationResult<string>.Failure(
				ErrorCode.Unauthorized, "character changed" )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
			static () => new SendChatCommand( "ic", "hello" ) );

		CollectionAssert.DoesNotContain( host.Calls, "execute" );
		Assert.AreEqual( "character changed", host.Sent[0].Result.Error!.Message );
	}

	[TestMethod]
	public void CharacterLifecycleCommandsSkipTheStableCharacterResolution()
	{
		foreach ( var factory in new Func<ClientCommand>[]
		{
			static () => new RequestCharacterListCommand(),
			static () => new LoadCharacterCommand( CharacterId.New() ),
			static () => new DeleteCharacterCommand( CharacterId.New() ),
			static () => new UnloadCharacterCommand()
		} )
		{
			var host = new FakeDispatchHost();
			CommandDispatchOrchestrator.Dispatch( host, Header(), 1, factory );
			CollectionAssert.DoesNotContain( host.Calls, "resolve:stable",
				$"{factory().GetType().Name} must dispatch without an active character." );
			Assert.AreEqual( "actor", host.Sent[0].Target );
		}
	}

	[TestMethod]
	public void HostOperationRefusalCompletesWithDrainingConflict()
	{
		var host = new FakeDispatchHost { AcceptHostOperation = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.Conflict, host.Sent[0].Result.Error!.Code );
		StringAssert.Contains( host.StartedOperationName!, "command:" );
	}

	[TestMethod]
	public void StaleLeaseInsideTheHostOperationRejectsBeforeExecution()
	{
		var host = new FakeDispatchHost { LeaseCurrent = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.DoesNotContain( host.Calls, "execute" );
		Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void MissingHostApplicationCompletesWithInternalError()
	{
		var host = new FakeDispatchHost { ExecutionAvailable = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.DoesNotContain( host.Calls, "execute" );
		Assert.AreEqual( ErrorCode.InternalError, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ExecutionFaultBecomesInternalError()
	{
		var host = new FakeDispatchHost
		{
			Execution = static () => throw new InvalidOperationException( "handler exploded" )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.HasCount( 1, host.ExecutionFaults );
		Assert.AreEqual( ErrorCode.InternalError, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ExecutionFaultUnderCancellationBecomesUnauthorized()
	{
		using var cancellation = new CancellationTokenSource();
		cancellation.Cancel();
		var host = new FakeDispatchHost
		{
			Cancellation = cancellation.Token,
			Execution = static () => throw new OperationCanceledException()
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void DisconnectedCompletionSendsNothing()
	{
		var host = new FakeDispatchHost { CompletionStatus = CommandCompletionStatus.Disconnected };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.IsEmpty( host.Sent );
	}

	[TestMethod]
	public void StaleCompletionKeepsASuccessfulExecutionResultButRejectsAFailedOne()
	{
		var success = new FakeDispatchHost { CompletionStatus = CommandCompletionStatus.Stale };
		CommandDispatchOrchestrator.Dispatch( success, Header(), 1, static () => new RequestCharacterListCommand() );
		Assert.IsTrue( success.Sent[0].Result.Succeeded,
			"A successful application result stays authoritative even under a stale lease." );

		var failure = new FakeDispatchHost
		{
			CompletionStatus = CommandCompletionStatus.Stale,
			Execution = static () => ValueTask.FromResult(
				OperationResult.Failure( ErrorCode.NotFound, "gone" ) )
		};
		CommandDispatchOrchestrator.Dispatch( failure, Header(), 1, static () => new RequestCharacterListCommand() );
		Assert.AreEqual( ErrorCode.Unauthorized, failure.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void SuccessfulExecutionForwardsTheApplicationResult()
	{
		var host = new FakeDispatchHost
		{
			Execution = static () => ValueTask.FromResult(
				OperationResult.Failure( ErrorCode.PolicyDenied, "application said no" ) )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.PolicyDenied, host.Sent[0].Result.Error!.Code );
		Assert.AreEqual( "application said no", host.Sent[0].Result.Error!.Message );
	}

	[TestMethod]
	public void ThrowingCompletionHookIsObservedWithoutEscaping()
	{
		var host = new FakeDispatchHost { CompleteThrows = true };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.HasCount( 1, host.CompletionFaults );
		Assert.IsEmpty( host.Sent );
	}

	[TestMethod]
	public void CommandCostTableIsPinned()
	{
		var table = new Dictionary<string, int>( StringComparer.Ordinal )
		{
			[nameof( ClientCommandCosts.CharacterList )] = ClientCommandCosts.CharacterList,
			[nameof( ClientCommandCosts.CreateCharacter )] = ClientCommandCosts.CreateCharacter,
			[nameof( ClientCommandCosts.LoadCharacter )] = ClientCommandCosts.LoadCharacter,
			[nameof( ClientCommandCosts.DeleteCharacter )] = ClientCommandCosts.DeleteCharacter,
			[nameof( ClientCommandCosts.UnloadCharacter )] = ClientCommandCosts.UnloadCharacter,
			[nameof( ClientCommandCosts.MoveItem )] = ClientCommandCosts.MoveItem,
			[nameof( ClientCommandCosts.ItemAction )] = ClientCommandCosts.ItemAction,
			[nameof( ClientCommandCosts.DropItem )] = ClientCommandCosts.DropItem,
			[nameof( ClientCommandCosts.PickupItem )] = ClientCommandCosts.PickupItem,
			[nameof( ClientCommandCosts.Chat )] = ClientCommandCosts.Chat,
			[nameof( ClientCommandCosts.CancelAction )] = ClientCommandCosts.CancelAction,
			[nameof( ClientCommandCosts.BeginInteraction )] = ClientCommandCosts.BeginInteraction,
			[nameof( ClientCommandCosts.ContinueInteraction )] = ClientCommandCosts.ContinueInteraction,
			[nameof( ClientCommandCosts.CloseInteraction )] = ClientCommandCosts.CloseInteraction,
			[nameof( ClientCommandCosts.SchemaCommandFallback )] = ClientCommandCosts.SchemaCommandFallback
		};
		var expected = new Dictionary<string, int>( StringComparer.Ordinal )
		{
			["CharacterList"] = 1,
			["CreateCharacter"] = 4,
			["LoadCharacter"] = 4,
			["DeleteCharacter"] = 4,
			["UnloadCharacter"] = 1,
			["MoveItem"] = 4,
			["ItemAction"] = 4,
			["DropItem"] = 4,
			["PickupItem"] = 4,
			["Chat"] = 2,
			["CancelAction"] = 1,
			["BeginInteraction"] = 2,
			["ContinueInteraction"] = 1,
			["CloseInteraction"] = 1,
			["SchemaCommandFallback"] = 8
		};
		CollectionAssert.AreEquivalent( expected, table );
	}

	[TestMethod]
	public void OnlyCharacterLifecycleCommandsAreExemptFromTheStableCharacterRequirement()
	{
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter( new RequestCharacterListCommand() ) );
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter(
			new LoadCharacterCommand( CharacterId.New() ) ) );
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter(
			new DeleteCharacterCommand( CharacterId.New() ) ) );
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter( new UnloadCharacterCommand() ) );
		Assert.IsTrue( CommandDispatchOrchestrator.RequiresStableCharacter( new SendChatCommand( "ic", "hi" ) ) );
		Assert.IsTrue( CommandDispatchOrchestrator.RequiresStableCharacter(
			new CloseInteractionCommand( InteractionSessionId.New() ) ) );
	}

	private static ClientSessionScope Scope() =>
		new( ClientSessionNonce.New(), ConnectionEpoch.New() );

	private static ClientCommandHeader Header() =>
		new( Scope(), new CommandRequestId( Guid.NewGuid() ) );

	private sealed class FakeDispatchHost : ICommandDispatchHost<string>
	{
		public List<string> Calls { get; } = new();
		public bool Authenticated { get; set; } = true;
		public bool ExecutionAvailable { get; set; } = true;
		public CommandAdmissionResult Admission { get; set; } = CommandAdmissionResult.Success();
		public OperationResult<string> Resolution { get; set; } = OperationResult<string>.Success( "actor" );
		public OperationResult<string> StableResolution { get; set; } = OperationResult<string>.Success( "stable-actor" );
		public bool LeaseCurrent { get; set; } = true;
		public bool AcceptHostOperation { get; set; } = true;
		public bool CompleteThrows { get; set; }
		public Func<ValueTask<OperationResult>>? Execution { get; set; }
		public CancellationToken Cancellation { get; set; }
		public CommandCompletionStatus CompletionStatus { get; set; } = CommandCompletionStatus.Current;
		public List<(string Target, OperationResult Result)> Sent { get; } = new();
		public List<Exception> MalformedPayloads { get; } = new();
		public List<Exception> ExecutionFaults { get; } = new();
		public List<Exception> CompletionFaults { get; } = new();
		public string? StartedOperationName { get; private set; }

		public bool CallerIsAuthenticated => Authenticated;
		public bool IsExecutionAvailable => ExecutionAvailable;

		public CommandAdmissionResult TryBeginCommand( CommandRequestId requestId, int cost )
		{
			Calls.Add( $"charge:{cost}" );
			return Admission;
		}

		public OperationResult<string> ResolveActor( ClientSessionScope scope, bool requireStableCharacter )
		{
			Calls.Add( requireStableCharacter ? "resolve:stable" : "resolve:initial" );
			return requireStableCharacter ? StableResolution : Resolution;
		}

		public void FinishRejectedCommand( CommandRequestId requestId ) => Calls.Add( "finish-rejected" );

		public bool IsCommandLeaseCurrent( string actor )
		{
			Calls.Add( "lease-check" );
			return LeaseCurrent;
		}

		public bool TryStartHostOperation( string name, Func<Task> operation )
		{
			Calls.Add( "host-operation" );
			StartedOperationName = name;
			if ( !AcceptHostOperation ) return false;
			operation().GetAwaiter().GetResult();
			return true;
		}

		public CancellationToken CommandCancellation( string actor ) => Cancellation;

		public ValueTask<OperationResult> ExecuteAsync(
			string actor,
			ClientCommand command,
			CancellationToken cancellationToken )
		{
			Calls.Add( "execute" );
			return Execution?.Invoke() ?? ValueTask.FromResult( OperationResult.Success() );
		}

		public CommandCompletionStatus CompleteCommand( string actor, CommandRequestId requestId )
		{
			Calls.Add( "complete" );
			if ( CompleteThrows ) throw new InvalidOperationException( "completion registry exploded" );
			return CompletionStatus;
		}

		public void SendResultToCaller( ClientSessionScope scope, CommandRequestId requestId, OperationResult result ) =>
			Sent.Add( ("caller", result) );

		public void SendResult( string actor, CommandRequestId requestId, OperationResult result ) =>
			Sent.Add( (actor, result) );

		public void OnMalformedPayload( Exception exception ) => MalformedPayloads.Add( exception );
		public void OnExecutionFault( CommandRequestId requestId, Exception exception ) => ExecutionFaults.Add( exception );
		public void OnCompletionFault( CommandRequestId requestId, Exception exception ) => CompletionFaults.Add( exception );
	}
}