From 94177dfb6de2f5f3bf8ae16305150b57ad7f5923 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Tue, 19 May 2026 07:36:17 +1000 Subject: [PATCH 1/7] fix: eliminate MessageId collisions in dialogue routing Replace the ambiguous FindDialogueForMessage search (which could match the wrong dialogue due to MessageId reuse across dialogues, uplinks, and downlinks) with four explicit paths: - BeginDialogueCommand / BeginDialogueCommandHandler: controller starts a new dialogue with an uplink (no search) - ReplyToDownlinkCommand / ReplyToDownlinkCommandHandler: controller replies to a known downlink, looks up dialogue by ID (no search) - NewDialogueFromDownlinkNotification / handler: aircraft initiates, MessageReference is null - AircraftRepliedToUplinkNotification / handler: aircraft replies, MessageReference is set; searches open dialogues by uplink ID only FindDialogueForMessage is replaced by FindOpenDialogueByUplink, scoped to open (non-archived) dialogues and uplink messages only, eliminating both the uplink/downlink type collision and the cross-dialogue collision. SendUplinkCommand is retained for system-internal use (logon, error responses) where no server-side dialogue ID is available. --- .../Messages/BeginDialogueRequest.cs | 32 ++++++++++ .../Messages/ReplyToDownlinkRequest.cs | 36 +++++++++++ .../Messages/SendStandbyUplinkRequest.cs | 17 +++-- .../CPDLCPlugin/Messages/SendUplinkRequest.cs | 35 ---------- .../Server/SignalRConnectionManager.cs | 23 +++++-- .../CPDLCPlugin/ViewModels/EditorViewModel.cs | 29 ++++++--- ...ownlinkReceivedNotificationHandlerTests.cs | 4 +- .../Handlers/SendUplinkCommandHandlerTests.cs | 4 +- .../Mocks/TestDialogueRepository.cs | 8 +-- ...craftRepliedToUplinkNotificationHandler.cs | 38 +++++++++++ .../Handlers/BeginDialogueCommandHandler.cs | 61 ++++++++++++++++++ .../DownlinkReceivedNotificationHandler.cs | 28 +------- ...DialogueFromDownlinkNotificationHandler.cs | 23 +++++++ .../Handlers/ReplyToDownlinkCommandHandler.cs | 64 +++++++++++++++++++ .../Handlers/SendUplinkCommandHandler.cs | 2 +- ...TransmitPendingNdaUplinksCommandHandler.cs | 3 +- source/CPDLCServer/Hubs/ControllerHub.cs | 41 ++++++++++-- .../AircraftRepliedToUplinkNotification.cs | 6 ++ .../Messages/BeginDialogueCommand.cs | 11 ++++ .../NewDialogueFromDownlinkNotification.cs | 6 ++ .../Messages/ReplyToDownlinkCommand.cs | 12 ++++ .../Persistence/IDialogueRepository.cs | 4 +- .../Persistence/InMemoryDialogueRepository.cs | 7 +- 23 files changed, 391 insertions(+), 103 deletions(-) create mode 100644 source/CPDLCPlugin/Messages/BeginDialogueRequest.cs create mode 100644 source/CPDLCPlugin/Messages/ReplyToDownlinkRequest.cs delete mode 100644 source/CPDLCPlugin/Messages/SendUplinkRequest.cs create mode 100644 source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs create mode 100644 source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs create mode 100644 source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs create mode 100644 source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs create mode 100644 source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs create mode 100644 source/CPDLCServer/Messages/BeginDialogueCommand.cs create mode 100644 source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs create mode 100644 source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs diff --git a/source/CPDLCPlugin/Messages/BeginDialogueRequest.cs b/source/CPDLCPlugin/Messages/BeginDialogueRequest.cs new file mode 100644 index 0000000..bfd487d --- /dev/null +++ b/source/CPDLCPlugin/Messages/BeginDialogueRequest.cs @@ -0,0 +1,32 @@ +using CPDLCServer.Contracts; +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record BeginDialogueRequest( + string Recipient, + CpdlcUplinkResponseType ResponseType, + string Content) + : IRequest; + +public class BeginDialogueRequestHandler(Plugin plugin, ILogger logger) + : IRequestHandler +{ + public async Task Handle(BeginDialogueRequest request, CancellationToken cancellationToken) + { + logger.Information("Beginning dialogue with {Recipient}", request.Recipient); + + if (plugin.ConnectionManager is null || !plugin.ConnectionManager.IsConnected) + { + logger.Warning("Not connected to server"); + return; + } + + await plugin.ConnectionManager.BeginDialogue( + request.Recipient, + request.ResponseType, + request.Content, + cancellationToken); + } +} diff --git a/source/CPDLCPlugin/Messages/ReplyToDownlinkRequest.cs b/source/CPDLCPlugin/Messages/ReplyToDownlinkRequest.cs new file mode 100644 index 0000000..05692a2 --- /dev/null +++ b/source/CPDLCPlugin/Messages/ReplyToDownlinkRequest.cs @@ -0,0 +1,36 @@ +using CPDLCServer.Contracts; +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record ReplyToDownlinkRequest( + string Recipient, + Guid DialogueId, + int DownlinkMessageId, + CpdlcUplinkResponseType ResponseType, + string Content) + : IRequest; + +public class ReplyToDownlinkRequestHandler(Plugin plugin, ILogger logger) + : IRequestHandler +{ + public async Task Handle(ReplyToDownlinkRequest request, CancellationToken cancellationToken) + { + logger.Information("Replying to downlink {DownlinkMessageId} in dialogue {DialogueId} for {Recipient}", + request.DownlinkMessageId, request.DialogueId, request.Recipient); + + if (plugin.ConnectionManager is null || !plugin.ConnectionManager.IsConnected) + { + logger.Warning("Not connected to server"); + return; + } + + await plugin.ConnectionManager.ReplyToDownlink( + request.DialogueId, + request.DownlinkMessageId, + request.ResponseType, + request.Content, + cancellationToken); + } +} diff --git a/source/CPDLCPlugin/Messages/SendStandbyUplinkRequest.cs b/source/CPDLCPlugin/Messages/SendStandbyUplinkRequest.cs index dd9683a..f77a80f 100644 --- a/source/CPDLCPlugin/Messages/SendStandbyUplinkRequest.cs +++ b/source/CPDLCPlugin/Messages/SendStandbyUplinkRequest.cs @@ -1,12 +1,12 @@ -using CPDLCServer.Contracts; +using CPDLCServer.Contracts; using MediatR; using Serilog; namespace CPDLCPlugin.Messages; -public record SendStandbyUplinkRequest(int DownlinkMessageId, string Recipient) : IRequest; -public record SendDeferredUplinkRequest(int DownlinkMessageId, string Recipient) : IRequest; -public record SendUnableUplinkRequest(int DownlinkMessageId, string Recipient, string Reason = "") : IRequest; +public record SendStandbyUplinkRequest(Guid DialogueId, int DownlinkMessageId, string Recipient) : IRequest; +public record SendDeferredUplinkRequest(Guid DialogueId, int DownlinkMessageId, string Recipient) : IRequest; +public record SendUnableUplinkRequest(Guid DialogueId, int DownlinkMessageId, string Recipient, string Reason = "") : IRequest; public class SendStandbyUplinkRequestHandler(IMediator mediator, ILogger logger) : IRequestHandler @@ -17,8 +17,9 @@ public async Task Handle(SendStandbyUplinkRequest request, CancellationToken can request.Recipient, request.DownlinkMessageId); await mediator.Send( - new SendUplinkRequest( + new ReplyToDownlinkRequest( request.Recipient, + request.DialogueId, request.DownlinkMessageId, CpdlcUplinkResponseType.NoResponse, // TODO: Is this the correct response type? "STANDBY"), @@ -35,8 +36,9 @@ public async Task Handle(SendDeferredUplinkRequest request, CancellationToken ca request.Recipient, request.DownlinkMessageId); await mediator.Send( - new SendUplinkRequest( + new ReplyToDownlinkRequest( request.Recipient, + request.DialogueId, request.DownlinkMessageId, CpdlcUplinkResponseType.NoResponse, // TODO: Is this the correct response type? "REQUEST DEFERRED"), @@ -59,8 +61,9 @@ public async Task Handle(SendUnableUplinkRequest request, CancellationToken canc request.Recipient, request.DownlinkMessageId, request.Reason ?? "none"); await mediator.Send( - new SendUplinkRequest( + new ReplyToDownlinkRequest( request.Recipient, + request.DialogueId, request.DownlinkMessageId, CpdlcUplinkResponseType.NoResponse, // TODO: Is this the correct response type? content), diff --git a/source/CPDLCPlugin/Messages/SendUplinkRequest.cs b/source/CPDLCPlugin/Messages/SendUplinkRequest.cs deleted file mode 100644 index b84121f..0000000 --- a/source/CPDLCPlugin/Messages/SendUplinkRequest.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CPDLCServer.Contracts; -using MediatR; -using Serilog; - -namespace CPDLCPlugin.Messages; - -public record SendUplinkRequest( - string Recipient, - int? ReplyToDownlinkId, - CpdlcUplinkResponseType ResponseType, - string Content) - : IRequest; - -public class SendUplinkRequestHandler(Plugin plugin, ILogger logger) - : IRequestHandler -{ - public async Task Handle(SendUplinkRequest request, CancellationToken cancellationToken) - { - logger.Information("Sending uplink to {Recipient} (ReplyTo: {ReplyToDownlinkId}, Type: {ResponseType}) with content: {Content}", - request.Recipient, request.ReplyToDownlinkId, request.ResponseType, request.Content); - - if (plugin.ConnectionManager is null || !plugin.ConnectionManager.IsConnected) - { - logger.Warning("Not connected to server"); - return; - } - - await plugin.ConnectionManager.SendUplink( - request.Recipient, - request.ReplyToDownlinkId, - request.ResponseType, - request.Content, - cancellationToken); - } -} diff --git a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs index ac3c669..fc0741e 100644 --- a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs +++ b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs @@ -144,18 +144,33 @@ Func WithCancellationToken(Func SendUplink( + public async Task BeginDialogue( string recipient, - int? replyToDownlinkId, CpdlcUplinkResponseType responseType, string content, CancellationToken cancellationToken) { var connection = GetConnectedOrThrow(); return await connection.InvokeAsync( - "SendUplink", + "BeginDialogue", recipient, - replyToDownlinkId, + responseType, + content, + cancellationToken: cancellationToken); + } + + public async Task ReplyToDownlink( + Guid dialogueId, + int downlinkMessageId, + CpdlcUplinkResponseType responseType, + string content, + CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + return await connection.InvokeAsync( + "ReplyToDownlink", + dialogueId, + downlinkMessageId, responseType, content, cancellationToken: cancellationToken); diff --git a/source/CPDLCPlugin/ViewModels/EditorViewModel.cs b/source/CPDLCPlugin/ViewModels/EditorViewModel.cs index 4324a65..050edd5 100644 --- a/source/CPDLCPlugin/ViewModels/EditorViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/EditorViewModel.cs @@ -282,7 +282,7 @@ async Task SendStandbyUplinkMessage() try { // Send the "STANDBY" uplink message - await _mediator.Send(new SendStandbyUplinkRequest(SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign)); + await _mediator.Send(new SendStandbyUplinkRequest(SelectedDownlinkMessage!.Dialogue.Id, SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign)); SelectedDownlinkMessage = null; ClearUplinkMessage(); } @@ -298,7 +298,7 @@ async Task Defer() try { // Send the "REQUEST DEFERRED" uplink message - await _mediator.Send(new SendDeferredUplinkRequest(SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign)); + await _mediator.Send(new SendDeferredUplinkRequest(SelectedDownlinkMessage!.Dialogue.Id, SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign)); SelectedDownlinkMessage = null; ClearUplinkMessage(); } @@ -327,7 +327,7 @@ async Task SendUnableDueTrafficUplinkMessage() try { // Send the "UNABLE" and "DUE TO TRAFFIC" uplink messages - await _mediator.Send(new SendUnableUplinkRequest(SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign, "DUE TO TRAFFIC.")); + await _mediator.Send(new SendUnableUplinkRequest(SelectedDownlinkMessage!.Dialogue.Id, SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign, "DUE TO TRAFFIC.")); // TODO: Do we need to do this? DialogueChanged will kick-in and remove it anyway var newDownlinkMessages = DownlinkMessages.Where(m => m != SelectedDownlinkMessage); @@ -348,7 +348,7 @@ async Task SendUnableDueAirspaceUplinkMessage() try { // Send the "UNABLE" and "DUE TO AIRSPACE RESTRICTION" uplink messages - await _mediator.Send(new SendUnableUplinkRequest(SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign, "DUE TO AIRSPACE RESTRICTION.")); + await _mediator.Send(new SendUnableUplinkRequest(SelectedDownlinkMessage!.Dialogue.Id, SelectedDownlinkMessage!.OriginalMessage.MessageId, Callsign, "DUE TO AIRSPACE RESTRICTION.")); // TODO: Do we need to do this? DialogueChanged will kick-in and remove it anyway var newDownlinkMessages = DownlinkMessages.Where(m => m != SelectedDownlinkMessage); @@ -585,11 +585,22 @@ async Task SendUplinkMessage() _logger.Debug("[{Callsign}] No downlink selected - sending uplink without MessageReference", Callsign); } - await _mediator.Send(new SendUplinkRequest( - Callsign, - downlinkMessage?.OriginalMessage.MessageId, - uplinkMessageResponseType, - uplinkMessageContent)); + if (downlinkMessage is not null) + { + await _mediator.Send(new ReplyToDownlinkRequest( + Callsign, + downlinkMessage.Dialogue.Id, + downlinkMessage.OriginalMessage.MessageId, + uplinkMessageResponseType, + uplinkMessageContent)); + } + else + { + await _mediator.Send(new BeginDialogueRequest( + Callsign, + uplinkMessageResponseType, + uplinkMessageContent)); + } ClearUplinkMessage(); diff --git a/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs index 98e8ca8..2dca93f 100644 --- a/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs @@ -403,7 +403,7 @@ public async Task Handle_CreatesNewDialogue_ForDownlinkWithNoReference() await handler.Handle(notification, CancellationToken.None); // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( + var dialogue = await dialogueRepository.FindOpenDialogueByUplink( "UAL123", 1, CancellationToken.None); @@ -469,7 +469,7 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() await handler.Handle(notification, CancellationToken.None); // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( + var dialogue = await dialogueRepository.FindOpenDialogueByUplink( "UAL123", 5, CancellationToken.None); diff --git a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs index a28f28f..860cbc0 100644 --- a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs @@ -151,7 +151,7 @@ public async Task Handle_CreatesNewDialogue_ForUplinkWithNoReference() var result = await handler.Handle(command, CancellationToken.None); // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( + var dialogue = await dialogueRepository.FindOpenDialogueByUplink( "UAL123", result.UplinkMessage.MessageId, CancellationToken.None); @@ -211,7 +211,7 @@ public async Task Handle_AppendsToExistingDialogue_ForUplinkWithReference() var result = await handler.Handle(command, CancellationToken.None); // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( + var dialogue = await dialogueRepository.FindOpenDialogueByUplink( "UAL123", 5, CancellationToken.None); diff --git a/source/CPDLCServer.Tests/Mocks/TestDialogueRepository.cs b/source/CPDLCServer.Tests/Mocks/TestDialogueRepository.cs index d9fe53e..bd9f3a9 100644 --- a/source/CPDLCServer.Tests/Mocks/TestDialogueRepository.cs +++ b/source/CPDLCServer.Tests/Mocks/TestDialogueRepository.cs @@ -12,14 +12,14 @@ public Task Add(Dialogue dialogue, CancellationToken cancellationToken) return _inner.Add(dialogue, cancellationToken); } - public Task FindDialogueForMessage( + public Task FindOpenDialogueByUplink( string aircraftCallsign, - int messageId, + int uplinkMessageId, CancellationToken cancellationToken) { - return _inner.FindDialogueForMessage( + return _inner.FindOpenDialogueByUplink( aircraftCallsign, - messageId, + uplinkMessageId, cancellationToken); } diff --git a/source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs b/source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs new file mode 100644 index 0000000..2c87374 --- /dev/null +++ b/source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs @@ -0,0 +1,38 @@ +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class AircraftRepliedToUplinkNotificationHandler( + IDialogueRepository dialogueRepository, + IPublisher publisher, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(AircraftRepliedToUplinkNotification notification, CancellationToken cancellationToken) + { + var downlink = notification.Downlink; + var dialogue = await dialogueRepository.FindOpenDialogueByUplink( + downlink.Sender, + downlink.MessageReference!.Value, + cancellationToken); + + if (dialogue is not null) + { + dialogue.AddMessage(downlink); + logger.Information("Downlink from {Callsign} appended to dialogue {DialogueId}", + downlink.Sender, dialogue.Id); + } + else + { + logger.Warning("No open dialogue found for uplink reference {MessageReference} from {Callsign} - starting new dialogue", + downlink.MessageReference.Value, downlink.Sender); + dialogue = new Dialogue(downlink.Sender, downlink); + await dialogueRepository.Add(dialogue, cancellationToken); + } + + await publisher.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + } +} diff --git a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs new file mode 100644 index 0000000..7d572b2 --- /dev/null +++ b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs @@ -0,0 +1,61 @@ +using CPDLCServer.Clients; +using CPDLCServer.Infrastructure; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class BeginDialogueCommandHandler( + IAircraftRepository aircraftRepository, + IClientManager clientManager, + IMessageIdProvider messageIdProvider, + IDialogueRepository dialogueRepository, + IMediator mediator, + IClock clock, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(BeginDialogueCommand request, CancellationToken cancellationToken) + { + logger.Information("Beginning new dialogue with {Recipient} (Content: {Content})", + request.Recipient, request.Content); + + var allAircraft = await aircraftRepository.All(cancellationToken); + var aircraftConnection = + allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.CurrentDataAuthority) + ?? allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.NextDataAuthority); + if (aircraftConnection is null) + throw new Exception($"{request.Recipient} is not connected"); + + var client = await clientManager.GetAcarsClient(aircraftConnection.AcarsClientId, cancellationToken); + + var messageId = await messageIdProvider.GetNextMessageId( + aircraftConnection.AcarsClientId, + request.Recipient, + cancellationToken); + + var uplinkMessage = new UplinkMessage( + messageId, + null, + request.Recipient, + request.Sender, + request.ResponseType, + AlertType.None, + request.Content, + clock.UtcNow()); + + var dialogue = new Dialogue(request.Recipient, uplinkMessage); + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for uplink {MessageId} to {Callsign}", + dialogue.Id, messageId, request.Recipient); + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + + await client.Send(uplinkMessage, cancellationToken); + logger.Information("Sent CPDLC message from {Sender} to {Recipient}", request.Sender, request.Recipient); + + return new SendUplinkResult(uplinkMessage); + } +} diff --git a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs index b6b5563..9b72d3a 100644 --- a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs +++ b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs @@ -106,31 +106,9 @@ await hubContext.Clients aircraftConnection.LogLastSeen(clock.UtcNow()); - // Add or update the dialogue - dialogue = notification.Downlink.MessageReference.HasValue - ? await dialogueRepository.FindDialogueForMessage( - notification.Downlink.Sender, - notification.Downlink.MessageReference.Value, - cancellationToken) - : null; - - if (dialogue is null) - { - dialogue = new Dialogue(notification.Downlink.Sender, notification.Downlink); - logger.Information("Dialogue {DialogueId} created for downlink from {Callsign}", dialogue.Id, notification.Downlink.Sender); - await dialogueRepository.Add(dialogue, cancellationToken); - } + if (notification.Downlink.MessageReference.HasValue) + await publisher.Publish(new AircraftRepliedToUplinkNotification(notification.Downlink), cancellationToken); else - { - dialogue.AddMessage(notification.Downlink); - logger.Information("Downlink from {Callsign} appended to dialogue {DialogueId}", notification.Downlink.Sender, dialogue.Id); - } - - // Publish DialogueChangedNotification instead of broadcasting directly - await publisher.Publish(new DialogueChangedNotification(dialogue), cancellationToken); - - logger.Information( - "Published dialogue change notification for downlink from {From}", - notification.Downlink.Sender); + await publisher.Publish(new NewDialogueFromDownlinkNotification(notification.Downlink), cancellationToken); } } diff --git a/source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs b/source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs new file mode 100644 index 0000000..b46c27c --- /dev/null +++ b/source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs @@ -0,0 +1,23 @@ +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class NewDialogueFromDownlinkNotificationHandler( + IDialogueRepository dialogueRepository, + IPublisher publisher, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(NewDialogueFromDownlinkNotification notification, CancellationToken cancellationToken) + { + var dialogue = new Dialogue(notification.Downlink.Sender, notification.Downlink); + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for downlink from {Callsign}", + dialogue.Id, notification.Downlink.Sender); + + await publisher.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + } +} diff --git a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs new file mode 100644 index 0000000..fbd56b6 --- /dev/null +++ b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs @@ -0,0 +1,64 @@ +using CPDLCServer.Clients; +using CPDLCServer.Infrastructure; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class ReplyToDownlinkCommandHandler( + IAircraftRepository aircraftRepository, + IClientManager clientManager, + IMessageIdProvider messageIdProvider, + IDialogueRepository dialogueRepository, + IMediator mediator, + IClock clock, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(ReplyToDownlinkCommand request, CancellationToken cancellationToken) + { + logger.Information("Replying to downlink {DownlinkMessageId} in dialogue {DialogueId} for {Recipient}", + request.DownlinkMessageId, request.DialogueId, request.Recipient); + + var dialogue = await dialogueRepository.FindById(request.DialogueId, cancellationToken); + if (dialogue is null) + throw new Exception($"Dialogue {request.DialogueId} not found"); + + var allAircraft = await aircraftRepository.All(cancellationToken); + var aircraftConnection = + allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.CurrentDataAuthority) + ?? allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.NextDataAuthority); + if (aircraftConnection is null) + throw new Exception($"{request.Recipient} is not connected"); + + var client = await clientManager.GetAcarsClient(aircraftConnection.AcarsClientId, cancellationToken); + + var messageId = await messageIdProvider.GetNextMessageId( + aircraftConnection.AcarsClientId, + request.Recipient, + cancellationToken); + + var uplinkMessage = new UplinkMessage( + messageId, + request.DownlinkMessageId, + request.Recipient, + request.Sender, + request.ResponseType, + AlertType.None, + request.Content, + clock.UtcNow()); + + dialogue.AddMessage(uplinkMessage); + logger.Information("Uplink {MessageId} added to dialogue {DialogueId} as reply to downlink {DownlinkMessageId}", + messageId, request.DialogueId, request.DownlinkMessageId); + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + + await client.Send(uplinkMessage, cancellationToken); + logger.Information("Sent CPDLC message from {Sender} to {Recipient}", request.Sender, request.Recipient); + + return new SendUplinkResult(uplinkMessage); + } +} diff --git a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs b/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs index e1b4622..cdfb338 100644 --- a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs +++ b/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs @@ -59,7 +59,7 @@ public async Task Handle(SendUplinkCommand request, Cancellati // Add or update the dialogue var dialogue = request.ReplyToDownlinkId.HasValue - ? await dialogueRepository.FindDialogueForMessage( + ? await dialogueRepository.FindOpenDialogueByUplink( request.Recipient, request.ReplyToDownlinkId.Value, cancellationToken) diff --git a/source/CPDLCServer/Handlers/TransmitPendingNdaUplinksCommandHandler.cs b/source/CPDLCServer/Handlers/TransmitPendingNdaUplinksCommandHandler.cs index b2ebb30..7dc8e2c 100644 --- a/source/CPDLCServer/Handlers/TransmitPendingNdaUplinksCommandHandler.cs +++ b/source/CPDLCServer/Handlers/TransmitPendingNdaUplinksCommandHandler.cs @@ -61,10 +61,9 @@ public async Task Handle(TransmitPendingNdaUplinksCommand request, CancellationT } await mediator.Send( - new SendUplinkCommand( + new BeginDialogueCommand( aircraftConnection.StationId, aircraftConnection.Callsign, - null, CpdlcUplinkResponseType.NoResponse, $"NEXT DATA AUTHORITY @{aircraftConnection.NextDataAuthority}@"), cancellationToken); diff --git a/source/CPDLCServer/Hubs/ControllerHub.cs b/source/CPDLCServer/Hubs/ControllerHub.cs index d2377bf..f24a549 100644 --- a/source/CPDLCServer/Hubs/ControllerHub.cs +++ b/source/CPDLCServer/Hubs/ControllerHub.cs @@ -71,9 +71,8 @@ await mediator.Publish( await base.OnConnectedAsync(); } - public async Task SendUplink( + public async Task BeginDialogue( string recipient, - int? replyToDownlinkId, CpdlcUplinkResponseType responseType, string content) { @@ -84,7 +83,6 @@ public async Task SendUplink( throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); } - // TODO: Move to converter var modelResponseType = responseType switch { CpdlcUplinkResponseType.NoResponse => Model.CpdlcUplinkResponseType.NoResponse, @@ -94,14 +92,43 @@ public async Task SendUplink( _ => throw new ArgumentOutOfRangeException(nameof(responseType), responseType, null) }; - var command = new SendUplinkCommand( + var result = await mediator.Send(new BeginDialogueCommand( controller.Callsign, recipient, - replyToDownlinkId, modelResponseType, - content); + content)); - var result = await mediator.Send(command); + return DialogueConverter.ToDto(result.UplinkMessage); + } + + public async Task ReplyToDownlink( + Guid dialogueId, + int downlinkMessageId, + CpdlcUplinkResponseType responseType, + string content) + { + var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); + if (controller is null) + { + _logger.Warning("Controller not found for connection {ConnectionId}", Context.ConnectionId); + throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); + } + + var modelResponseType = responseType switch + { + CpdlcUplinkResponseType.NoResponse => Model.CpdlcUplinkResponseType.NoResponse, + CpdlcUplinkResponseType.WilcoUnable => Model.CpdlcUplinkResponseType.WilcoUnable, + CpdlcUplinkResponseType.AffirmativeNegative => Model.CpdlcUplinkResponseType.AffirmativeNegative, + CpdlcUplinkResponseType.Roger => Model.CpdlcUplinkResponseType.Roger, + _ => throw new ArgumentOutOfRangeException(nameof(responseType), responseType, null) + }; + + var result = await mediator.Send(new ReplyToDownlinkCommand( + controller.Callsign, + dialogueId, + downlinkMessageId, + modelResponseType, + content)); return DialogueConverter.ToDto(result.UplinkMessage); } diff --git a/source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs b/source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs new file mode 100644 index 0000000..d297c46 --- /dev/null +++ b/source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs @@ -0,0 +1,6 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record AircraftRepliedToUplinkNotification(DownlinkMessage Downlink) : INotification; diff --git a/source/CPDLCServer/Messages/BeginDialogueCommand.cs b/source/CPDLCServer/Messages/BeginDialogueCommand.cs new file mode 100644 index 0000000..6b52922 --- /dev/null +++ b/source/CPDLCServer/Messages/BeginDialogueCommand.cs @@ -0,0 +1,11 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record BeginDialogueCommand( + string Sender, + string Recipient, + CpdlcUplinkResponseType ResponseType, + string Content) + : IRequest; diff --git a/source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs b/source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs new file mode 100644 index 0000000..1ebe93d --- /dev/null +++ b/source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs @@ -0,0 +1,6 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record NewDialogueFromDownlinkNotification(DownlinkMessage Downlink) : INotification; diff --git a/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs b/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs new file mode 100644 index 0000000..031439d --- /dev/null +++ b/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs @@ -0,0 +1,12 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record ReplyToDownlinkCommand( + string Sender, + Guid DialogueId, + int DownlinkMessageId, + CpdlcUplinkResponseType ResponseType, + string Content) + : IRequest; diff --git a/source/CPDLCServer/Persistence/IDialogueRepository.cs b/source/CPDLCServer/Persistence/IDialogueRepository.cs index 6d4e13f..e201349 100644 --- a/source/CPDLCServer/Persistence/IDialogueRepository.cs +++ b/source/CPDLCServer/Persistence/IDialogueRepository.cs @@ -7,9 +7,9 @@ public interface IDialogueRepository { Task Add(Dialogue dialogue, CancellationToken cancellationToken); - Task FindDialogueForMessage( + Task FindOpenDialogueByUplink( string aircraftCallsign, - int messageId, + int uplinkMessageId, CancellationToken cancellationToken); Task FindById(Guid id, CancellationToken cancellationToken); diff --git a/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs b/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs index 482255b..60559d4 100644 --- a/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs +++ b/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs @@ -17,9 +17,9 @@ public async Task Add(Dialogue dialogue, CancellationToken cancellationToken) } } - public async Task FindDialogueForMessage( + public async Task FindOpenDialogueByUplink( string aircraftCallsign, - int messageId, + int uplinkMessageId, CancellationToken cancellationToken) { using (await _semaphore.LockAsync(cancellationToken)) @@ -27,7 +27,8 @@ public async Task Add(Dialogue dialogue, CancellationToken cancellationToken) return _dialogues .FirstOrDefault(d => d.AircraftCallsign == aircraftCallsign && - d.Messages.Any(m => m.MessageId == messageId)); + !d.IsArchived && + d.Messages.OfType().Any(m => m.MessageId == uplinkMessageId)); } } From 7219b0209626357edf23cf623d39420564ed3edc Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 20 May 2026 06:49:36 +1000 Subject: [PATCH 2/7] fix: add missing CPDLCServer.Services using to new handlers --- source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs | 1 + source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs index 7d572b2..d8ca304 100644 --- a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs +++ b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs @@ -3,6 +3,7 @@ using CPDLCServer.Messages; using CPDLCServer.Model; using CPDLCServer.Persistence; +using CPDLCServer.Services; using MediatR; namespace CPDLCServer.Handlers; diff --git a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs index fbd56b6..f2362d3 100644 --- a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs +++ b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs @@ -3,6 +3,7 @@ using CPDLCServer.Messages; using CPDLCServer.Model; using CPDLCServer.Persistence; +using CPDLCServer.Services; using MediatR; namespace CPDLCServer.Handlers; From 27e731bd02e603622ec842ef47c377096ae4486b Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 20 May 2026 07:02:39 +1000 Subject: [PATCH 3/7] fix: derive callsign from dialogue in ReplyToDownlinkCommandHandler --- .../Handlers/ReplyToDownlinkCommandHandler.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs index f2362d3..ad79301 100644 --- a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs +++ b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs @@ -20,31 +20,32 @@ public class ReplyToDownlinkCommandHandler( { public async Task Handle(ReplyToDownlinkCommand request, CancellationToken cancellationToken) { - logger.Information("Replying to downlink {DownlinkMessageId} in dialogue {DialogueId} for {Recipient}", - request.DownlinkMessageId, request.DialogueId, request.Recipient); - var dialogue = await dialogueRepository.FindById(request.DialogueId, cancellationToken); if (dialogue is null) throw new Exception($"Dialogue {request.DialogueId} not found"); + var callsign = dialogue.AircraftCallsign; + logger.Information("Replying to downlink {DownlinkMessageId} in dialogue {DialogueId} for {Callsign}", + request.DownlinkMessageId, request.DialogueId, callsign); + var allAircraft = await aircraftRepository.All(cancellationToken); var aircraftConnection = - allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.CurrentDataAuthority) - ?? allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.NextDataAuthority); + allAircraft.FirstOrDefault(a => a.Callsign == callsign && a.DataAuthorityState == DataAuthorityState.CurrentDataAuthority) + ?? allAircraft.FirstOrDefault(a => a.Callsign == callsign && a.DataAuthorityState == DataAuthorityState.NextDataAuthority); if (aircraftConnection is null) - throw new Exception($"{request.Recipient} is not connected"); + throw new Exception($"{callsign} is not connected"); var client = await clientManager.GetAcarsClient(aircraftConnection.AcarsClientId, cancellationToken); var messageId = await messageIdProvider.GetNextMessageId( aircraftConnection.AcarsClientId, - request.Recipient, + callsign, cancellationToken); var uplinkMessage = new UplinkMessage( messageId, request.DownlinkMessageId, - request.Recipient, + callsign, request.Sender, request.ResponseType, AlertType.None, @@ -58,7 +59,7 @@ public async Task Handle(ReplyToDownlinkCommand request, Cance await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); await client.Send(uplinkMessage, cancellationToken); - logger.Information("Sent CPDLC message from {Sender} to {Recipient}", request.Sender, request.Recipient); + logger.Information("Sent CPDLC message from {Sender} to {Callsign}", request.Sender, callsign); return new SendUplinkResult(uplinkMessage); } From c5fca8b508197fb8be0db9a8f87bc51e55ab0257 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 23 May 2026 15:20:57 +1000 Subject: [PATCH 4/7] wip: Track dialogue ID in message models --- .gitignore | 4 +- source/CPDLCServer.Contracts/AcarsMessages.cs | 1 + .../CPDLCServer/Clients/HoppieAcarsClient.cs | 8 +- source/CPDLCServer/Clients/IAcarsClient.cs | 2 +- ...rcraftConnectionLostNotificationHandler.cs | 9 +- ...craftRepliedToUplinkNotificationHandler.cs | 38 -------- .../Handlers/BeginDialogueCommandHandler.cs | 4 +- .../DownlinkReceivedNotificationHandler.cs | 86 ++++++++++--------- ...DialogueFromDownlinkNotificationHandler.cs | 23 ----- .../Handlers/ReplyToDownlinkCommandHandler.cs | 3 +- .../Handlers/SendUplinkCommandHandler.cs | 39 ++------- .../AircraftRepliedToUplinkNotification.cs | 6 -- .../Messages/DownlinkReceivedNotification.cs | 2 +- .../NewDialogueFromDownlinkNotification.cs | 6 -- source/CPDLCServer/Model/ControlMessages.cs | 16 ++-- source/CPDLCServer/Model/Dialogue.cs | 40 +++++++-- source/CPDLCServer/Model/DialogueConverter.cs | 2 + source/CPDLCServer/Model/DownlinkMessage.cs | 2 + source/CPDLCServer/Model/ICpdlcMessage.cs | 1 + source/CPDLCServer/Model/ReceivedDownlink.cs | 11 +++ source/CPDLCServer/Model/UplinkMessage.cs | 2 + .../Persistence/InMemoryDialogueRepository.cs | 2 +- 22 files changed, 129 insertions(+), 178 deletions(-) delete mode 100644 source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs delete mode 100644 source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs delete mode 100644 source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs delete mode 100644 source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs create mode 100644 source/CPDLCServer/Model/ReceivedDownlink.cs diff --git a/.gitignore b/.gitignore index 009c056..b07f59f 100644 --- a/.gitignore +++ b/.gitignore @@ -147,4 +147,6 @@ npm-debug.log .env # Database -*.db* \ No newline at end of file +*.db* + +/logs \ No newline at end of file diff --git a/source/CPDLCServer.Contracts/AcarsMessages.cs b/source/CPDLCServer.Contracts/AcarsMessages.cs index f689631..a196ecc 100644 --- a/source/CPDLCServer.Contracts/AcarsMessages.cs +++ b/source/CPDLCServer.Contracts/AcarsMessages.cs @@ -59,6 +59,7 @@ public record DialogueDto( [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] public abstract class CpdlcMessageDto { + public required Guid DialogueId { get; init; } public required int MessageId { get; init; } public int? MessageReference { get; init; } public required AlertType AlertType { get; init; } diff --git a/source/CPDLCServer/Clients/HoppieAcarsClient.cs b/source/CPDLCServer/Clients/HoppieAcarsClient.cs index 5fae27b..d0e3049 100644 --- a/source/CPDLCServer/Clients/HoppieAcarsClient.cs +++ b/source/CPDLCServer/Clients/HoppieAcarsClient.cs @@ -20,7 +20,7 @@ public class HoppieAcarsClient : IAcarsClient readonly IClock _clock; readonly ILogger _logger; - readonly Channel _messageChannel = Channel.CreateUnbounded(); + readonly Channel _messageChannel = Channel.CreateUnbounded(); readonly Random _random = Random.Shared; CancellationTokenSource? _pollCancellationTokenSource; @@ -29,7 +29,7 @@ public class HoppieAcarsClient : IAcarsClient bool _disposed; - public ChannelReader MessageReader => _messageChannel.Reader; + public ChannelReader MessageReader => _messageChannel.Reader; public string StationId => _configuration.StationIdentifier; public HoppieAcarsClient(HoppiesConfiguration configuration, HttpClient httpClient, IClock clock, ILogger logger) @@ -349,7 +349,7 @@ static List ExtractMessages(string responseText) return (headerParts[0], headerParts[1], packet); } - DownlinkMessage ParseCpdlcDownlink(string from, string packet) + ReceivedDownlink ParseCpdlcDownlink(string from, string packet) { var parts = packet.Split('/', 6); if (parts.Length != 6) @@ -366,7 +366,7 @@ DownlinkMessage ParseCpdlcDownlink(string from, string packet) var content = parts[5]; - return new DownlinkMessage( + return new ReceivedDownlink( messageId, replyToId, from, diff --git a/source/CPDLCServer/Clients/IAcarsClient.cs b/source/CPDLCServer/Clients/IAcarsClient.cs index 8d7ebac..0aceefa 100644 --- a/source/CPDLCServer/Clients/IAcarsClient.cs +++ b/source/CPDLCServer/Clients/IAcarsClient.cs @@ -6,7 +6,7 @@ namespace CPDLCServer.Clients; public interface IAcarsClient : IAsyncDisposable { - ChannelReader MessageReader { get; } + ChannelReader MessageReader { get; } string StationId { get; } Task Connect(CancellationToken cancellationToken); Task Send(UplinkMessage message, CancellationToken cancellationToken); diff --git a/source/CPDLCServer/Handlers/AircraftConnectionLostNotificationHandler.cs b/source/CPDLCServer/Handlers/AircraftConnectionLostNotificationHandler.cs index 9189e5e..e8f817a 100644 --- a/source/CPDLCServer/Handlers/AircraftConnectionLostNotificationHandler.cs +++ b/source/CPDLCServer/Handlers/AircraftConnectionLostNotificationHandler.cs @@ -83,7 +83,7 @@ await aircraftRepository.Remove( notification.Callsign, cancellationToken); - var errorDownlink = new DownlinkMessage( + dialogue.AddDownlink( messageId, openMessage.MessageId, notification.Callsign, @@ -92,8 +92,6 @@ await aircraftRepository.Remove( "ERROR CONNECTION TIMED OUT", clock.UtcNow()); - dialogue.AddMessage(errorDownlink); - // Publish DialogueChangedNotification await publisher.Publish(new DialogueChangedNotification(dialogue), cancellationToken); @@ -111,7 +109,8 @@ await aircraftRepository.Remove( notification.Callsign, cancellationToken); - var errorDownlink = new DownlinkMessage( + var dialogue = new Dialogue(notification.Callsign); + dialogue.AddDownlink( messageId, null, notification.Callsign, @@ -120,8 +119,6 @@ await aircraftRepository.Remove( "ERROR CONNECTION TIMED OUT", clock.UtcNow()); - var dialogue = new Dialogue(notification.Callsign, errorDownlink); - await dialogueRepository.Add(dialogue, cancellationToken); // Publish DialogueChangedNotification diff --git a/source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs b/source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs deleted file mode 100644 index 2c87374..0000000 --- a/source/CPDLCServer/Handlers/AircraftRepliedToUplinkNotificationHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using CPDLCServer.Messages; -using CPDLCServer.Model; -using CPDLCServer.Persistence; -using MediatR; - -namespace CPDLCServer.Handlers; - -public class AircraftRepliedToUplinkNotificationHandler( - IDialogueRepository dialogueRepository, - IPublisher publisher, - ILogger logger) - : INotificationHandler -{ - public async Task Handle(AircraftRepliedToUplinkNotification notification, CancellationToken cancellationToken) - { - var downlink = notification.Downlink; - var dialogue = await dialogueRepository.FindOpenDialogueByUplink( - downlink.Sender, - downlink.MessageReference!.Value, - cancellationToken); - - if (dialogue is not null) - { - dialogue.AddMessage(downlink); - logger.Information("Downlink from {Callsign} appended to dialogue {DialogueId}", - downlink.Sender, dialogue.Id); - } - else - { - logger.Warning("No open dialogue found for uplink reference {MessageReference} from {Callsign} - starting new dialogue", - downlink.MessageReference.Value, downlink.Sender); - dialogue = new Dialogue(downlink.Sender, downlink); - await dialogueRepository.Add(dialogue, cancellationToken); - } - - await publisher.Publish(new DialogueChangedNotification(dialogue), cancellationToken); - } -} diff --git a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs index d8ca304..a72227a 100644 --- a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs +++ b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs @@ -37,7 +37,8 @@ public async Task Handle(BeginDialogueCommand request, Cancell request.Recipient, cancellationToken); - var uplinkMessage = new UplinkMessage( + var dialogue = new Dialogue(request.Recipient); + var uplinkMessage = dialogue.AddUplink( messageId, null, request.Recipient, @@ -47,7 +48,6 @@ public async Task Handle(BeginDialogueCommand request, Cancell request.Content, clock.UtcNow()); - var dialogue = new Dialogue(request.Recipient, uplinkMessage); await dialogueRepository.Add(dialogue, cancellationToken); logger.Information("Dialogue {DialogueId} created for uplink {MessageId} to {Callsign}", dialogue.Id, messageId, request.Recipient); diff --git a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs index 9b72d3a..bfada43 100644 --- a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs +++ b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs @@ -15,76 +15,55 @@ public class DownlinkReceivedNotificationHandler( IControllerRepository controllerRepository, IDialogueRepository dialogueRepository, IHubContext hubContext, - IPublisher publisher, ILogger logger) : INotificationHandler { public async Task Handle(DownlinkReceivedNotification notification, CancellationToken cancellationToken) { - logger.Information("Downlink message received from {Callsign}", notification.Downlink.Sender); + var downlink = notification.Downlink; + logger.Information("Downlink message received from {Callsign}", downlink.Sender); - // Intercept logon requests and automatically respond - Dialogue? dialogue; - if (ControlMessages.IsLogonRequest(notification.Downlink)) + // Branch 1: Logon request + if (ControlMessages.IsLogonRequest(downlink)) { - // Create a dialogue for the logon request - dialogue = new Dialogue( - notification.Downlink.Sender, - notification.Downlink); + var dialogue = new Dialogue(downlink.Sender); + dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); await dialogueRepository.Add(dialogue, cancellationToken); - await mediator.Send( - new LogonCommand( - notification.Downlink.MessageId, - notification.Downlink.Sender, - notification.AcarsClientId), - cancellationToken); + await mediator.Send(new LogonCommand(downlink.MessageId, downlink.Sender, notification.AcarsClientId), cancellationToken); return; } var aircraftConnection = await aircraftRepository.Find( - new (notification.Downlink.Sender, notification.AcarsClientId), + new(downlink.Sender, notification.AcarsClientId), cancellationToken); + // Branch 2: Unknown aircraft if (aircraftConnection is null) { - logger.Information("{Callsign} is not known by this ATSU, sending error uplink", notification.Downlink.Sender); - - // Connection not known, reject. + logger.Information("{Callsign} is not known by this ATSU, sending error uplink", downlink.Sender); await mediator.Send( - new SendUplinkCommand( - "SYSTEM", - notification.Downlink.Sender, - notification.Downlink.MessageId, - CpdlcUplinkResponseType.NoResponse, - "ERROR. CONNECTION NOT ESTABLISHED."), + new SendUplinkCommand("SYSTEM", downlink.Sender, downlink.MessageId, CpdlcUplinkResponseType.NoResponse, "ERROR. CONNECTION NOT ESTABLISHED."), cancellationToken); return; } // Intercept logoff messages - if (ControlMessages.IsLogoffNotice(notification.Downlink)) + if (ControlMessages.IsLogoffNotice(downlink)) { - await mediator.Send( - new TerminateConnectionRequest( - notification.Downlink.Sender, - notification.AcarsClientId), - cancellationToken); - + await mediator.Send(new TerminateConnectionRequest(downlink.Sender, notification.AcarsClientId), cancellationToken); // Allow these to flow through to the controller } // Promote aircraft to CurrentDataAuthority on first downlink, unless // the aircraft explicitly indicates we are not the current data authority if (aircraftConnection.DataAuthorityState == DataAuthorityState.NextDataAuthority && - !ControlMessages.IsNotCurrentDataAuthority(notification.Downlink)) + !ControlMessages.IsNotCurrentDataAuthority(downlink)) { aircraftConnection.PromoteToCurrentDataAuthority(); - logger.Information("{Callsign} promoted to CurrentDataAuthority", notification.Downlink.Sender); + logger.Information("{Callsign} promoted to CurrentDataAuthority", downlink.Sender); - // Notify all controllers that the aircraft has been promoted to CurrentDataAuthority var controllers = await controllerRepository.All(cancellationToken); - if (controllers.Any()) { await hubContext.Clients @@ -106,9 +85,36 @@ await hubContext.Clients aircraftConnection.LogLastSeen(clock.UtcNow()); - if (notification.Downlink.MessageReference.HasValue) - await publisher.Publish(new AircraftRepliedToUplinkNotification(notification.Downlink), cancellationToken); - else - await publisher.Publish(new NewDialogueFromDownlinkNotification(notification.Downlink), cancellationToken); + // Branch 3: Aircraft replies to an uplink + if (downlink.MessageReference.HasValue) + { + var dialogue = await dialogueRepository.FindOpenDialogueByUplink(downlink.Sender, downlink.MessageReference.Value, cancellationToken); + if (dialogue is not null) + { + dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); + logger.Information("Downlink from {Callsign} appended to dialogue {DialogueId}", downlink.Sender, dialogue.Id); + } + else + { + logger.Warning("No open dialogue found for uplink reference {MessageReference} from {Callsign} - starting new dialogue", + downlink.MessageReference.Value, downlink.Sender); + dialogue = new Dialogue(downlink.Sender); + dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); + await dialogueRepository.Add(dialogue, cancellationToken); + } + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + return; + } + + // Branch 4: Aircraft initiates a new dialogue + { + var dialogue = new Dialogue(downlink.Sender); + dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for downlink from {Callsign}", dialogue.Id, downlink.Sender); + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + } } } diff --git a/source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs b/source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs deleted file mode 100644 index b46c27c..0000000 --- a/source/CPDLCServer/Handlers/NewDialogueFromDownlinkNotificationHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CPDLCServer.Messages; -using CPDLCServer.Model; -using CPDLCServer.Persistence; -using MediatR; - -namespace CPDLCServer.Handlers; - -public class NewDialogueFromDownlinkNotificationHandler( - IDialogueRepository dialogueRepository, - IPublisher publisher, - ILogger logger) - : INotificationHandler -{ - public async Task Handle(NewDialogueFromDownlinkNotification notification, CancellationToken cancellationToken) - { - var dialogue = new Dialogue(notification.Downlink.Sender, notification.Downlink); - await dialogueRepository.Add(dialogue, cancellationToken); - logger.Information("Dialogue {DialogueId} created for downlink from {Callsign}", - dialogue.Id, notification.Downlink.Sender); - - await publisher.Publish(new DialogueChangedNotification(dialogue), cancellationToken); - } -} diff --git a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs index ad79301..6b8f719 100644 --- a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs +++ b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs @@ -42,7 +42,7 @@ public async Task Handle(ReplyToDownlinkCommand request, Cance callsign, cancellationToken); - var uplinkMessage = new UplinkMessage( + var uplinkMessage = dialogue.AddUplink( messageId, request.DownlinkMessageId, callsign, @@ -52,7 +52,6 @@ public async Task Handle(ReplyToDownlinkCommand request, Cance request.Content, clock.UtcNow()); - dialogue.AddMessage(uplinkMessage); logger.Information("Uplink {MessageId} added to dialogue {DialogueId} as reply to downlink {DownlinkMessageId}", messageId, request.DialogueId, request.DownlinkMessageId); diff --git a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs b/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs index cdfb338..17fb612 100644 --- a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs +++ b/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs @@ -38,7 +38,8 @@ public async Task Handle(SendUplinkCommand request, Cancellati request.Recipient, cancellationToken); - var uplinkMessage = new UplinkMessage( + var dialogue = new Dialogue(request.Recipient); + var uplinkMessage = dialogue.AddUplink( messageId, request.ReplyToDownlinkId, request.Recipient, @@ -48,40 +49,10 @@ public async Task Handle(SendUplinkCommand request, Cancellati request.Content, clock.UtcNow()); - if (request.ReplyToDownlinkId.HasValue) - { - logger.Debug("Uplink {MessageId} is a reply to downlink {DownlinkId}", messageId, request.ReplyToDownlinkId.Value); - } - else - { - logger.Debug("Uplink {MessageId} is NOT a reply (no MessageReference set)", messageId); - } + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for uplink message {MessageId} to {Callsign}", + dialogue.Id, messageId, request.Recipient); - // Add or update the dialogue - var dialogue = request.ReplyToDownlinkId.HasValue - ? await dialogueRepository.FindOpenDialogueByUplink( - request.Recipient, - request.ReplyToDownlinkId.Value, - cancellationToken) - : null; - - if (dialogue is null) - { - dialogue = new Dialogue(request.Recipient, uplinkMessage); - await dialogueRepository.Add(dialogue, cancellationToken); - logger.Information("Dialogue {DialogueId} created for uplink message {MessageId} to {Callsign}", - dialogue.Id, messageId, request.Recipient); - } - else - { - logger.Debug("Found dialogue {DialogueId} for downlink {DownlinkId}, adding uplink {UplinkId}", - dialogue.Id, request.ReplyToDownlinkId, messageId); - dialogue.AddMessage(uplinkMessage); - logger.Information("Uplink message {MessageId} to {Callsign} added to dialogue {DialogueId}", - messageId, request.Recipient, dialogue.Id); - } - - // Publish dialogue change notification await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); await client.Send(uplinkMessage, cancellationToken); diff --git a/source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs b/source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs deleted file mode 100644 index d297c46..0000000 --- a/source/CPDLCServer/Messages/AircraftRepliedToUplinkNotification.cs +++ /dev/null @@ -1,6 +0,0 @@ -using CPDLCServer.Model; -using MediatR; - -namespace CPDLCServer.Messages; - -public record AircraftRepliedToUplinkNotification(DownlinkMessage Downlink) : INotification; diff --git a/source/CPDLCServer/Messages/DownlinkReceivedNotification.cs b/source/CPDLCServer/Messages/DownlinkReceivedNotification.cs index 1c0852f..6d9167c 100644 --- a/source/CPDLCServer/Messages/DownlinkReceivedNotification.cs +++ b/source/CPDLCServer/Messages/DownlinkReceivedNotification.cs @@ -6,5 +6,5 @@ namespace CPDLCServer.Messages; public record DownlinkReceivedNotification( string AcarsClientId, string StationId, - DownlinkMessage Downlink) + ReceivedDownlink Downlink) : INotification; diff --git a/source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs b/source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs deleted file mode 100644 index 1ebe93d..0000000 --- a/source/CPDLCServer/Messages/NewDialogueFromDownlinkNotification.cs +++ /dev/null @@ -1,6 +0,0 @@ -using CPDLCServer.Model; -using MediatR; - -namespace CPDLCServer.Messages; - -public record NewDialogueFromDownlinkNotification(DownlinkMessage Downlink) : INotification; diff --git a/source/CPDLCServer/Model/ControlMessages.cs b/source/CPDLCServer/Model/ControlMessages.cs index c9aa880..9b97271 100644 --- a/source/CPDLCServer/Model/ControlMessages.cs +++ b/source/CPDLCServer/Model/ControlMessages.cs @@ -2,23 +2,23 @@ namespace CPDLCServer.Model; public static class ControlMessages { - public static bool IsLogonRequest(DownlinkMessage downlinkMessage) + public static bool IsLogonRequest(ReceivedDownlink downlink) { - return downlinkMessage.Content.Contains("REQUEST LOGON"); + return downlink.Content.Contains("REQUEST LOGON"); } - public static bool IsLogoffNotice(DownlinkMessage downlinkMessage) + public static bool IsLogoffNotice(ReceivedDownlink downlink) { - return downlinkMessage.Content.Contains("LOGOFF"); + return downlink.Content.Contains("LOGOFF"); } - public static bool IsEndServiceUplink(UplinkMessage downlinkMessage) + public static bool IsEndServiceUplink(UplinkMessage uplink) { - return downlinkMessage.Content.Contains("END SERVICE"); + return uplink.Content.Contains("END SERVICE"); } - public static bool IsNotCurrentDataAuthority(DownlinkMessage downlinkMessage) + public static bool IsNotCurrentDataAuthority(ReceivedDownlink downlink) { - return downlinkMessage.Content.Contains("NOT CURRENT DATA AUTHORITY"); + return downlink.Content.Contains("NOT CURRENT DATA AUTHORITY"); } } diff --git a/source/CPDLCServer/Model/Dialogue.cs b/source/CPDLCServer/Model/Dialogue.cs index 62f6002..abe1e05 100644 --- a/source/CPDLCServer/Model/Dialogue.cs +++ b/source/CPDLCServer/Model/Dialogue.cs @@ -18,18 +18,16 @@ public class Dialogue { readonly List _messages = []; - public Dialogue(string aircraftCallsign, ICpdlcMessage firstMessage) + public Dialogue(string aircraftCallsign) { AircraftCallsign = aircraftCallsign; - Opened = firstMessage.Time; - AddMessage(firstMessage); } public Guid Id { get; } = Guid.NewGuid(); public string AircraftCallsign { get; } public IReadOnlyList Messages => _messages.AsReadOnly(); - public DateTimeOffset Opened { get; } + public DateTimeOffset Opened { get; private set; } public DateTimeOffset? Closed { get; private set; } [MemberNotNullWhen(true, nameof(Closed))] @@ -40,8 +38,40 @@ public Dialogue(string aircraftCallsign, ICpdlcMessage firstMessage) [MemberNotNullWhen(true, nameof(Archived))] public bool IsArchived => Archived.HasValue; - public void AddMessage(ICpdlcMessage message) + public UplinkMessage AddUplink( + int messageId, + int? messageReference, + string recipient, + string senderCallsign, + CpdlcUplinkResponseType responseType, + AlertType alertType, + string content, + DateTimeOffset sent) { + var message = new UplinkMessage(Id, messageId, messageReference, recipient, senderCallsign, responseType, alertType, content, sent); + AddMessage(message); + return message; + } + + public DownlinkMessage AddDownlink( + int messageId, + int? messageReference, + string sender, + CpdlcDownlinkResponseType responseType, + AlertType alertType, + string content, + DateTimeOffset received) + { + var message = new DownlinkMessage(Id, messageId, messageReference, sender, responseType, alertType, content, received); + AddMessage(message); + return message; + } + + void AddMessage(ICpdlcMessage message) + { + if (_messages.Count == 0) + Opened = message.Time; + _messages.Add(message); // Apply closure rules then check if dialogue closes diff --git a/source/CPDLCServer/Model/DialogueConverter.cs b/source/CPDLCServer/Model/DialogueConverter.cs index f486c07..e1bac55 100644 --- a/source/CPDLCServer/Model/DialogueConverter.cs +++ b/source/CPDLCServer/Model/DialogueConverter.cs @@ -63,6 +63,7 @@ public static UplinkMessageDto ToDto(UplinkMessage uplink) { return new UplinkMessageDto { + DialogueId = uplink.DialogueId, MessageId = uplink.MessageId, MessageReference = uplink.MessageReference, AlertType = ToDto(uplink.AlertType), @@ -83,6 +84,7 @@ public static DownlinkMessageDto ToDto(DownlinkMessage downlink) { return new DownlinkMessageDto { + DialogueId = downlink.DialogueId, MessageId = downlink.MessageId, MessageReference = downlink.MessageReference, AlertType = ToDto(downlink.AlertType), diff --git a/source/CPDLCServer/Model/DownlinkMessage.cs b/source/CPDLCServer/Model/DownlinkMessage.cs index e9c62ce..b7c80f9 100644 --- a/source/CPDLCServer/Model/DownlinkMessage.cs +++ b/source/CPDLCServer/Model/DownlinkMessage.cs @@ -1,6 +1,7 @@ namespace CPDLCServer.Model; public class DownlinkMessage( + Guid dialogueId, int messageId, int? messageReference, string sender, @@ -10,6 +11,7 @@ public class DownlinkMessage( DateTimeOffset received) : ICpdlcMessage { + public Guid DialogueId { get; } = dialogueId; public int MessageId { get; } = messageId; public int? MessageReference { get; } = messageReference; public string Sender { get; } = sender; diff --git a/source/CPDLCServer/Model/ICpdlcMessage.cs b/source/CPDLCServer/Model/ICpdlcMessage.cs index a39e44e..03603a7 100644 --- a/source/CPDLCServer/Model/ICpdlcMessage.cs +++ b/source/CPDLCServer/Model/ICpdlcMessage.cs @@ -2,6 +2,7 @@ namespace CPDLCServer.Model; public interface ICpdlcMessage { + public Guid DialogueId { get; } public int MessageId { get; } public int? MessageReference { get; } AlertType AlertType { get; } diff --git a/source/CPDLCServer/Model/ReceivedDownlink.cs b/source/CPDLCServer/Model/ReceivedDownlink.cs new file mode 100644 index 0000000..405691a --- /dev/null +++ b/source/CPDLCServer/Model/ReceivedDownlink.cs @@ -0,0 +1,11 @@ +namespace CPDLCServer.Model; + +// Raw downlink data as parsed from an ACARS packet, before being assigned to a dialogue. +public record ReceivedDownlink( + int MessageId, + int? MessageReference, + string Sender, + CpdlcDownlinkResponseType ResponseType, + AlertType AlertType, + string Content, + DateTimeOffset Received); diff --git a/source/CPDLCServer/Model/UplinkMessage.cs b/source/CPDLCServer/Model/UplinkMessage.cs index 431a67a..c58f0a9 100644 --- a/source/CPDLCServer/Model/UplinkMessage.cs +++ b/source/CPDLCServer/Model/UplinkMessage.cs @@ -3,6 +3,7 @@ namespace CPDLCServer.Model; // TODO: Separate formatted and plaintext contents. public class UplinkMessage( + Guid dialogueId, int messageId, int? messageReference, string recipient, @@ -13,6 +14,7 @@ public class UplinkMessage( DateTimeOffset sent) : ICpdlcMessage { + public Guid DialogueId { get; } = dialogueId; public int MessageId { get; } = messageId; public int? MessageReference { get; } = messageReference; public string Recipient { get; } = recipient; diff --git a/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs b/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs index 60559d4..b169347 100644 --- a/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs +++ b/source/CPDLCServer/Persistence/InMemoryDialogueRepository.cs @@ -27,7 +27,7 @@ public async Task Add(Dialogue dialogue, CancellationToken cancellationToken) return _dialogues .FirstOrDefault(d => d.AircraftCallsign == aircraftCallsign && - !d.IsArchived && + !d.IsClosed && d.Messages.OfType().Any(m => m.MessageId == uplinkMessageId)); } } From da4acf5101d2366fa99b34d5f838ac841510302c Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 23 May 2026 16:17:18 +1000 Subject: [PATCH 5/7] test: update tests for new dialogue routing API and add regression tests for #34 - Fix all test compilation errors caused by domain model API changes (Dialogue.AddUplink/AddDownlink, ReceivedDownlink, updated constructors) - Add BeginDialogueCommandHandlerTests and ReplyToDownlinkCommandHandlerTests - Add regression tests verifying replies append to existing dialogues rather than creating new ones, and that DialogueId lookup is used when multiple dialogues share the same downlink MessageId - Add DownlinkReceivedNotificationHandler tests for fallback-to-new-dialogue, closed-dialogue isolation, and logoff message handling --- .../Clients/HoppieAcarsClientTests.cs | 44 ++- .../AcknowledgeDownlinkCommandHandlerTests.cs | 20 +- .../AcknowledgeUplinkCommandHandlerTests.cs | 35 +-- ...tConnectionLostNotificationHandlerTests.cs | 4 +- .../ArchiveDialogueCommandHandlerTests.cs | 24 +- .../BeginDialogueCommandHandlerTests.cs | 154 +++++++++++ ...ownlinkReceivedNotificationHandlerTests.cs | 251 +++++++++++++---- .../ReplyToDownlinkCommandHandlerTests.cs | 255 ++++++++++++++++++ .../Handlers/SendUplinkCommandHandlerTests.cs | 34 +-- ...eConnectionOnDialogueClosedHandlerTests.cs | 31 ++- ...mitPendingNdaUplinksCommandHandlerTests.cs | 10 +- .../Mocks/TestAcarsClient.cs | 4 +- .../CPDLCServer.Tests/Model/DialogueTests.cs | 134 ++++----- .../Services/MessageMonitorServiceTests.cs | 57 ++-- 14 files changed, 766 insertions(+), 291 deletions(-) create mode 100644 source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs diff --git a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs index 566c2bc..e9d092d 100644 --- a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs +++ b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs @@ -61,6 +61,7 @@ public async Task Send_CpdlcMessage_FormatsPayloadCorrectly() await client.Connect(CancellationToken.None); var message = new UplinkMessage( + Guid.NewGuid(), 1, null, "UAL123", @@ -99,6 +100,7 @@ public async Task Send_CpdlcReply_IncludesReplyToId() await client.Connect(CancellationToken.None); var reply = new UplinkMessage( + Guid.NewGuid(), 2, 5, "UAL123", @@ -139,6 +141,7 @@ public async Task Send_CpdlcMessage_MapsResponseTypesCorrectly(CpdlcUplinkRespon await client.Connect(CancellationToken.None); var message = new UplinkMessage( + Guid.NewGuid(), 1, null, "UAL123", @@ -178,6 +181,7 @@ public async Task Send_CpdlcMessage_TranslatesContent() await client.Connect(CancellationToken.None); var message = new UplinkMessage( + Guid.NewGuid(), 1, null, "UAL123", @@ -214,6 +218,7 @@ public async Task Send_CpdlcMessage_UrlEncodesContent() await client.Connect(CancellationToken.None); var message = new UplinkMessage( + Guid.NewGuid(), 1, null, "UAL123", @@ -255,11 +260,10 @@ public async Task Poll_CpdlcMessage_ParsesCorrectly() var message = await client.MessageReader.ReadAsync(cts.Token); // Assert - var cpdlcMessage = Assert.IsType(message); - Assert.Equal("UAL123", cpdlcMessage.Sender); - Assert.Equal(5, cpdlcMessage.MessageId); - Assert.Equal("REQUEST DESCENT", cpdlcMessage.Content); - Assert.Equal(CpdlcDownlinkResponseType.ResponseRequired, cpdlcMessage.ResponseType); + Assert.Equal("UAL123", message.Sender); + Assert.Equal(5, message.MessageId); + Assert.Equal("REQUEST DESCENT", message.Content); + Assert.Equal(CpdlcDownlinkResponseType.ResponseRequired, message.ResponseType); } [Fact] @@ -279,12 +283,11 @@ public async Task Poll_CpdlcReply_ParsesCorrectly() var message = await client.MessageReader.ReadAsync(cts.Token); // Assert - var cpdlcReply = Assert.IsType(message); - Assert.Equal("UAL123", cpdlcReply.Sender); - Assert.Equal(7, cpdlcReply.MessageId); - Assert.Equal(3, cpdlcReply.MessageReference); - Assert.Equal("WILCO", cpdlcReply.Content); - Assert.Equal(CpdlcDownlinkResponseType.NoResponse, cpdlcReply.ResponseType); + Assert.Equal("UAL123", message.Sender); + Assert.Equal(7, message.MessageId); + Assert.Equal(3, message.MessageReference); + Assert.Equal("WILCO", message.Content); + Assert.Equal(CpdlcDownlinkResponseType.NoResponse, message.ResponseType); } [Fact] @@ -306,13 +309,11 @@ public async Task Poll_MultipleMessages_ParsesAllCorrectly() var message2 = await client.MessageReader.ReadAsync(cts.Token); // Assert - var cpdlcMessage1 = Assert.IsType(message1); - Assert.Equal("UAL123", cpdlcMessage1.Sender); - Assert.Equal("REQUEST DESCENT", cpdlcMessage1.Content); + Assert.Equal("UAL123", message1.Sender); + Assert.Equal("REQUEST DESCENT", message1.Content); - var cpdlcMessage2 = Assert.IsType(message2); - Assert.Equal("DAL456", cpdlcMessage2.Sender); - Assert.Equal("REQUEST CLIMB", cpdlcMessage2.Content); + Assert.Equal("DAL456", message2.Sender); + Assert.Equal("REQUEST CLIMB", message2.Content); } [Fact] @@ -333,8 +334,7 @@ public async Task Poll_MessageWithSeparator_ParsesCorrectly() var message = await client.MessageReader.ReadAsync(cts.Token); // Assert - var cpdlcMessage = Assert.IsType(message); - Assert.Equal("REQUEST CLIMB DUE TO A/C PERFORMANCE", cpdlcMessage.Content); + Assert.Equal("REQUEST CLIMB DUE TO A/C PERFORMANCE", message.Content); } [Theory] @@ -356,8 +356,7 @@ public async Task Poll_ResponseTypeCodes_MapCorrectly(string code, CpdlcDownlink var message = await client.MessageReader.ReadAsync(cts.Token); // Assert - var cpdlcMessage = Assert.IsType(message); - Assert.Equal(expectedType, cpdlcMessage.ResponseType); + Assert.Equal(expectedType, message.ResponseType); await client.DisposeAsync(); _clients.Remove(client); @@ -380,8 +379,7 @@ public async Task Poll_WithoutMessageReferenceNumbers_MapsCorrectly() var message = await client.MessageReader.ReadAsync(cts.Token); // Assert - var cpdlcMessage = Assert.IsType(message); - Assert.Equal(-1, cpdlcMessage.MessageId); + Assert.Equal(-1, message.MessageId); await client.DisposeAsync(); _clients.Remove(client); diff --git a/source/CPDLCServer.Tests/Handlers/AcknowledgeDownlinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/AcknowledgeDownlinkCommandHandlerTests.cs index 15fc71c..108b715 100644 --- a/source/CPDLCServer.Tests/Handlers/AcknowledgeDownlinkCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/AcknowledgeDownlinkCommandHandlerTests.cs @@ -16,7 +16,8 @@ public async Task Handle_AcknowledgesDownlinkMessage() var clock = new TestClock(); var publisher = new TestPublisher(); - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -24,8 +25,6 @@ public async Task Handle_AcknowledgesDownlinkMessage() AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", downlink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeDownlinkCommandHandler( @@ -55,7 +54,8 @@ public async Task Handle_PublishesDialogueChangedNotification() var clock = new TestClock(); var publisher = new TestPublisher(); - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink( 1, null, "UAL123", @@ -63,8 +63,6 @@ public async Task Handle_PublishesDialogueChangedNotification() AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", downlink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeDownlinkCommandHandler( @@ -93,7 +91,8 @@ public async Task Handle_DoesNotCloseDialogueWhenMessagesAreStillOpen() var publisher = new TestPublisher(); // Create a downlink that requires a response - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -101,8 +100,6 @@ public async Task Handle_DoesNotCloseDialogueWhenMessagesAreStillOpen() AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", downlink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeDownlinkCommandHandler( @@ -155,7 +152,8 @@ public async Task Handle_ThrowsWhenDownlinkMessageNotFoundInDialogue() var clock = new TestClock(); var publisher = new TestPublisher(); - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink( 1, null, "UAL123", @@ -163,8 +161,6 @@ public async Task Handle_ThrowsWhenDownlinkMessageNotFoundInDialogue() AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", downlink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeDownlinkCommandHandler( diff --git a/source/CPDLCServer.Tests/Handlers/AcknowledgeUplinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/AcknowledgeUplinkCommandHandlerTests.cs index 68d8d4b..d337c1e 100644 --- a/source/CPDLCServer.Tests/Handlers/AcknowledgeUplinkCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/AcknowledgeUplinkCommandHandlerTests.cs @@ -16,7 +16,8 @@ public async Task Handle_ClosesUplinkMessageManually() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -25,8 +26,6 @@ public async Task Handle_ClosesUplinkMessageManually() AlertType.None, "CLEARED DESCEND FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeUplinkCommandHandler( @@ -58,7 +57,8 @@ public async Task Handle_PublishesDialogueChangedNotification() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -67,8 +67,6 @@ public async Task Handle_PublishesDialogueChangedNotification() AlertType.None, "CLEARED DESCEND FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeUplinkCommandHandler( @@ -97,7 +95,8 @@ public async Task Handle_ClosesDialogueWhenAllMessagesAreClosed() var publisher = new TestPublisher(); // Create an uplink that requires a response - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -106,8 +105,6 @@ public async Task Handle_ClosesDialogueWhenAllMessagesAreClosed() AlertType.None, "CLEARED DESCEND FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeUplinkCommandHandler( @@ -138,8 +135,8 @@ public async Task Handle_KeepsDialogueOpenWhenOtherMessagesAreStillOpen() var clock = new TestClock(); var publisher = new TestPublisher(); - // Create first uplink - var uplink1 = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink1 = dialogue.AddUplink( 1, null, "UAL123", @@ -149,10 +146,7 @@ public async Task Handle_KeepsDialogueOpenWhenOtherMessagesAreStillOpen() "CLEARED DESCEND FL350", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", uplink1); - - // Add a second uplink message to the dialogue - var uplink2 = new UplinkMessage( + var uplink2 = dialogue.AddUplink( 2, null, "UAL123", @@ -162,7 +156,6 @@ public async Task Handle_KeepsDialogueOpenWhenOtherMessagesAreStillOpen() "CLEARED DIRECT WAYPOINT", clock.UtcNow()); - dialogue.AddMessage(uplink2); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeUplinkCommandHandler( @@ -218,7 +211,8 @@ public async Task Handle_ThrowsWhenUplinkMessageNotFoundInDialogue() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -227,8 +221,6 @@ public async Task Handle_ThrowsWhenUplinkMessageNotFoundInDialogue() AlertType.None, "CLEARED DESCEND FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeUplinkCommandHandler( @@ -254,7 +246,8 @@ public async Task Handle_OnlyFindsUplinkMessages() var publisher = new TestPublisher(); // Create a dialogue with a downlink (not an uplink) - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink( 1, null, "UAL123", @@ -262,8 +255,6 @@ public async Task Handle_OnlyFindsUplinkMessages() AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", downlink); await dialogueRepository.Add(dialogue, CancellationToken.None); var handler = new AcknowledgeUplinkCommandHandler( diff --git a/source/CPDLCServer.Tests/Handlers/AircraftConnectionLostNotificationHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/AircraftConnectionLostNotificationHandlerTests.cs index ecf87df..677e3f8 100644 --- a/source/CPDLCServer.Tests/Handlers/AircraftConnectionLostNotificationHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/AircraftConnectionLostNotificationHandlerTests.cs @@ -226,7 +226,8 @@ public async Task Handle_AddsErrorMessageToExistingOpenDialogue() var clock = new TestClock(); // Create an existing dialogue with an open downlink message - var existingDownlink = new DownlinkMessage( + var existingDialogue = new Dialogue("UAL123"); + existingDialogue.AddDownlink( 1, null, "UAL123", @@ -234,7 +235,6 @@ public async Task Handle_AddsErrorMessageToExistingOpenDialogue() AlertType.None, "REQUEST DESCENT TO FL350", clock.UtcNow()); - var existingDialogue = new Dialogue("UAL123", existingDownlink); await dialogueRepository.Add(existingDialogue, CancellationToken.None); var messageIdProvider = new TestMessageIdProvider(); diff --git a/source/CPDLCServer.Tests/Handlers/ArchiveDialogueCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/ArchiveDialogueCommandHandlerTests.cs index 5f54e2c..a2ab340 100644 --- a/source/CPDLCServer.Tests/Handlers/ArchiveDialogueCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/ArchiveDialogueCommandHandlerTests.cs @@ -16,13 +16,12 @@ public async Task Handle_ArchivesClosedDialogue() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", "BN-TSN_FSS", CpdlcUplinkResponseType.NoResponse, AlertType.None, "ROGER", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var handler = new ArchiveDialogueCommandHandler(repository, clock, publisher, Logger.None); @@ -44,20 +43,17 @@ public async Task Handle_AcknowledgesUnacknowledgedDownlinks_WhenArchiving() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", "BN-TSN_FSS", CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "CLIMB TO FL350", clock.UtcNow()); - - var downlink = new DownlinkMessage( + var downlink = dialogue.AddDownlink( 2, 1, "UAL123", CpdlcDownlinkResponseType.NoResponse, AlertType.None, "WILCO", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); - dialogue.AddMessage(downlink); await repository.Add(dialogue, CancellationToken.None); Assert.True(dialogue.IsClosed); @@ -82,13 +78,12 @@ public async Task Handle_DoesNotArchive_WhenDialogueIsOpen() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", "BN-TSN_FSS", CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "CLIMB TO FL350", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); Assert.False(dialogue.IsClosed); @@ -112,13 +107,12 @@ public async Task Handle_IsIdempotent_WhenAlreadyArchived() var clock = new TestClock(); var publisher = new TestPublisher(); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", "BN-TSN_FSS", CpdlcUplinkResponseType.NoResponse, AlertType.None, "ROGER", clock.UtcNow()); - - var dialogue = new Dialogue("UAL123", uplink); dialogue.Archive(clock.UtcNow()); await repository.Add(dialogue, CancellationToken.None); diff --git a/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs new file mode 100644 index 0000000..de7bf73 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs @@ -0,0 +1,154 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class BeginDialogueCommandHandlerTests +{ + [Fact] + public async Task Handle_CreatesNewDialogue() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var handler = new BeginDialogueCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("UAL123", dialogues[0].AircraftCallsign); + Assert.Single(dialogues[0].Messages); + Assert.Equal(result.UplinkMessage, dialogues[0].Messages[0]); + } + + [Fact] + public async Task Handle_UplinkHasNullMessageReference() + { + // A dialogue begun by the controller is never a reply - MessageReference must be null. + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var handler = new BeginDialogueCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + Assert.Null(result.UplinkMessage.MessageReference); + } + + [Fact] + public async Task Handle_SendsUplinkViaAcarsClient() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var handler = new BeginDialogueCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.SentMessages); + Assert.Equal("UAL123", client.SentMessages[0].Recipient); + Assert.Equal("CLIMB TO FL410", client.SentMessages[0].Content); + } + + [Fact] + public async Task Handle_PublishesDialogueChangedNotification() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var handler = new BeginDialogueCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ThrowsWhenAircraftNotConnected() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + // No aircraft added + + var handler = new BeginDialogueCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); + + // Act & Assert + await Assert.ThrowsAsync(() => handler.Handle(command, CancellationToken.None)); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs index 2dca93f..d50d5b8 100644 --- a/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs @@ -46,7 +46,6 @@ public async Task Handle_PublishesDialogueChangedNotification() var dialogueRepository = new TestDialogueRepository(); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftManager, mediator, @@ -54,10 +53,9 @@ public async Task Handle_PublishesDialogueChangedNotification() controllerManager, dialogueRepository, hubContext, - publisher, Logger.None); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -69,17 +67,20 @@ public async Task Handle_PublishesDialogueChangedNotification() var notification = new DownlinkReceivedNotification( "hoppies-ybbb", "YBBB", - downlinkMessage); + downlink); // Act await handler.Handle(notification, CancellationToken.None); - // Assert - DialogueChangedNotification is published - Assert.Single(publisher.PublishedNotifications.OfType()); - var dialogueNotification = publisher.PublishedNotifications.OfType().First(); - Assert.Equal("UAL123", dialogueNotification.Dialogue.AircraftCallsign); - Assert.Single(dialogueNotification.Dialogue.Messages); - Assert.Equal(downlinkMessage, dialogueNotification.Dialogue.Messages.First()); + // Assert - DialogueChangedNotification is published via mediator + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); + + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("UAL123", dialogues[0].AircraftCallsign); + Assert.Single(dialogues[0].Messages); + var msg = Assert.IsType(dialogues[0].Messages[0]); + Assert.Equal("REQUEST DESCENT", msg.Content); } [Fact] @@ -109,7 +110,6 @@ public async Task Handle_StillCreatesDialogueWhenNoControllersMatch() var dialogueRepository = new TestDialogueRepository(); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftManager, mediator, @@ -117,10 +117,9 @@ public async Task Handle_StillCreatesDialogueWhenNoControllersMatch() controllerManager, dialogueRepository, hubContext, - publisher, Logger.None); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -132,15 +131,16 @@ public async Task Handle_StillCreatesDialogueWhenNoControllersMatch() var notification = new DownlinkReceivedNotification( "hoppies-ybbb", "YBBB", - downlinkMessage); + downlink); // Act await handler.Handle(notification, CancellationToken.None); - // Assert - DialogueChangedNotification is still published even with no matching controllers - Assert.Single(publisher.PublishedNotifications.OfType()); - var dialogueNotification = publisher.PublishedNotifications.OfType().First(); - Assert.Equal("UAL123", dialogueNotification.Dialogue.AircraftCallsign); + // Assert - dialogue is still created even with no matching controllers + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("UAL123", dialogues[0].AircraftCallsign); } [Fact] @@ -170,7 +170,6 @@ public async Task Handle_PromotesAircraftToCurrentDataAuthorityOnFirstDownlink() var dialogueRepository = new TestDialogueRepository(); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftManager, mediator, @@ -178,10 +177,9 @@ public async Task Handle_PromotesAircraftToCurrentDataAuthorityOnFirstDownlink() controllerManager, dialogueRepository, hubContext, - publisher, Logger.None); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -193,7 +191,7 @@ public async Task Handle_PromotesAircraftToCurrentDataAuthorityOnFirstDownlink() var notification = new DownlinkReceivedNotification( "hoppies-ybbb", "YBBB", - downlinkMessage); + downlink); // Assert - aircraft starts as NextDataAuthority Assert.Equal(DataAuthorityState.NextDataAuthority, aircraft.DataAuthorityState); @@ -251,7 +249,6 @@ public async Task Handle_DoesNotPromoteToCurrentDataAuthority_WhenNotCurrentData var dialogueRepository = new TestDialogueRepository(); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftManager, mediator, @@ -259,10 +256,9 @@ public async Task Handle_DoesNotPromoteToCurrentDataAuthority_WhenNotCurrentData controllerManager, dialogueRepository, hubContext, - publisher, Logger.None); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -274,7 +270,7 @@ public async Task Handle_DoesNotPromoteToCurrentDataAuthority_WhenNotCurrentData var notification = new DownlinkReceivedNotification( "hoppies-ybbb", "YBBB", - downlinkMessage); + downlink); // Assert - aircraft starts as NextDataAuthority Assert.Equal(DataAuthorityState.NextDataAuthority, aircraft.DataAuthorityState); @@ -326,7 +322,6 @@ public async Task Handle_UpdatesLastSeen() var dialogueRepository = new TestDialogueRepository(); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftManager, mediator, @@ -334,10 +329,9 @@ public async Task Handle_UpdatesLastSeen() controllerManager, dialogueRepository, hubContext, - publisher, Logger.None); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -349,7 +343,7 @@ public async Task Handle_UpdatesLastSeen() var notification = new DownlinkReceivedNotification( "hoppies-ybbb", "YBBB", - downlinkMessage); + downlink); // Assert Assert.Equal(logonTime, aircraft.LastSeen); @@ -377,7 +371,6 @@ public async Task Handle_CreatesNewDialogue_ForDownlinkWithNoReference() var mediator = Substitute.For(); var hubContext = Substitute.For>(); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftRepository, mediator, @@ -385,10 +378,9 @@ public async Task Handle_CreatesNewDialogue_ForDownlinkWithNoReference() controllerRepository, dialogueRepository, hubContext, - publisher, Logger.None); - var downlink = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -402,15 +394,14 @@ public async Task Handle_CreatesNewDialogue_ForDownlinkWithNoReference() // Act await handler.Handle(notification, CancellationToken.None); - // Assert - var dialogue = await dialogueRepository.FindOpenDialogueByUplink( - "UAL123", - 1, - CancellationToken.None); - - Assert.NotNull(dialogue); - Assert.Single(dialogue.Messages); - Assert.Equal(downlink, dialogue.Messages[0]); + // Assert - a new dialogue was created with the downlink + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("UAL123", dialogues[0].AircraftCallsign); + Assert.Single(dialogues[0].Messages); + var msg = Assert.IsType(dialogues[0].Messages[0]); + Assert.Equal("REQUEST CLIMB FL410", msg.Content); + Assert.Equal(1, msg.MessageId); } [Fact] @@ -430,7 +421,8 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() var hubContext = Substitute.For>(); // Create existing dialogue with an uplink - var uplink = new UplinkMessage( + var existingDialogue = new Dialogue("UAL123"); + existingDialogue.AddUplink( 5, null, "UAL123", @@ -439,11 +431,8 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() AlertType.None, "CLIMB TO FL410", clock.UtcNow()); - - var existingDialogue = new Dialogue("UAL123", uplink); await dialogueRepository.Add(existingDialogue, CancellationToken.None); - var publisher = new TestPublisher(); var handler = new DownlinkReceivedNotificationHandler( aircraftRepository, mediator, @@ -451,10 +440,9 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() controllerRepository, dialogueRepository, hubContext, - publisher, Logger.None); - var downlink = new DownlinkMessage( + var downlink = new ReceivedDownlink( 10, 5, "UAL123", @@ -468,14 +456,167 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() // Act await handler.Handle(notification, CancellationToken.None); - // Assert - var dialogue = await dialogueRepository.FindOpenDialogueByUplink( + // Assert - downlink appended to the existing dialogue (use All since WILCO closes the dialogue) + var allDialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(allDialogues); + Assert.Equal(2, allDialogues[0].Messages.Count); + var appendedMsg = allDialogues[0].Messages.OfType().FirstOrDefault(m => m.MessageId == 10); + Assert.NotNull(appendedMsg); + Assert.Equal("WILCO", appendedMsg.Content); + } + + [Fact] + public async Task Handle_FallsBackToNewDialogue_WhenNoOpenDialogueMatchesReference() + { + // Branch 3 fallback: aircraft sends a reply referencing an uplink that no longer has an + // open dialogue (e.g. controller manually closed it). The handler must not drop the + // message - it creates a new dialogue instead. + // Arrange + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var hubContext = Substitute.For>(); + + var handler = new DownlinkReceivedNotificationHandler( + aircraftRepository, mediator, clock, controllerRepository, dialogueRepository, hubContext, Logger.None); + + // Downlink has a MessageReference (uplink ID=99) but no open dialogue exists for it + var downlink = new ReceivedDownlink( + 10, + 99, // References uplink that doesn't exist in any open dialogue "UAL123", - 5, - CancellationToken.None); + CpdlcDownlinkResponseType.NoResponse, + AlertType.None, + "WILCO", + clock.UtcNow()); + + var notification = new DownlinkReceivedNotification("hoppies-ybbb", "YBBB", downlink); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - a new dialogue was created as fallback (message is not dropped) + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("UAL123", dialogues[0].AircraftCallsign); + Assert.Single(dialogues[0].Messages); + var msg = Assert.IsType(dialogues[0].Messages[0]); + Assert.Equal(10, msg.MessageId); + Assert.Equal(99, msg.MessageReference); + + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_DoesNotAppendToClosedDialogue_WhenAircraftReusesMessageId() + { + // Regression: MessageId is scoped per ACARS session. If an aircraft re-uses a downlink + // MessageId from a previous (now closed) session, FindOpenDialogueByUplink must not + // match the closed dialogue - a new dialogue should be created instead. + // Arrange + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var hubContext = Substitute.For>(); + + // Prior session: uplink ID=5 was sent and fully replied to (dialogue is now closed) + var priorDialogue = new Dialogue("UAL123"); + priorDialogue.AddUplink( + 5, null, "UAL123", "YBBB", + CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "CLIMB FL410", + clock.UtcNow()); + priorDialogue.AddDownlink( + 3, 5, "UAL123", + CpdlcDownlinkResponseType.NoResponse, AlertType.None, "WILCO", + clock.UtcNow().AddSeconds(30)); + await dialogueRepository.Add(priorDialogue, CancellationToken.None); + + Assert.True(priorDialogue.IsClosed); + + var handler = new DownlinkReceivedNotificationHandler( + aircraftRepository, mediator, clock, controllerRepository, dialogueRepository, hubContext, Logger.None); + + // New session: aircraft re-uses MessageReference=5 (same uplink ID as in the old session) + var downlink = new ReceivedDownlink( + 10, + 5, // Same MessageReference as old session's uplink + "UAL123", + CpdlcDownlinkResponseType.NoResponse, + AlertType.None, + "WILCO", + clock.UtcNow().AddMinutes(30)); + + var notification = new DownlinkReceivedNotification("hoppies-ybbb", "YBBB", downlink); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - a NEW dialogue was created (closed dialogue was not reopened or appended to) + var allDialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Equal(2, allDialogues.Length); + + var newDialogue = allDialogues.First(d => d.Id != priorDialogue.Id); + Assert.Single(newDialogue.Messages); // Only the new downlink + Assert.Equal(2, priorDialogue.Messages.Count); // Prior dialogue is unchanged + } + + [Fact] + public async Task Handle_LogoffMessage_TerminatesConnectionAndCreatesDialogue() + { + // Logoff messages must: (1) trigger connection termination, (2) still flow through + // to create a dialogue so the controller sees the logoff notification. + // Arrange + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var hubContext = Substitute.For>(); + + var handler = new DownlinkReceivedNotificationHandler( + aircraftRepository, mediator, clock, controllerRepository, dialogueRepository, hubContext, Logger.None); + + var downlink = new ReceivedDownlink( + 1, null, "UAL123", + CpdlcDownlinkResponseType.NoResponse, AlertType.None, + "LOGOFF", + clock.UtcNow()); + + var notification = new DownlinkReceivedNotification("hoppies-ybbb", "YBBB", downlink); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - connection termination was requested + await mediator.Received(1).Send( + Arg.Is(r => r.Callsign == "UAL123" && r.AcarsClientId == "hoppies-ybbb"), + Arg.Any()); + + // Assert - logoff message still created a dialogue for the controller to see + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + var msg = Assert.IsType(dialogues[0].Messages[0]); + Assert.Equal("LOGOFF", msg.Content); - Assert.NotNull(dialogue); - Assert.Equal(2, dialogue.Messages.Count); - Assert.Contains(downlink, dialogue.Messages); + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); } } diff --git a/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs new file mode 100644 index 0000000..934025d --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs @@ -0,0 +1,255 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +// Regression tests for issue #34 - "Replies from the CPDLC Editor are starting new messages" +// Root cause: the old implementation searched for dialogues by MessageId, which is only scoped +// per-session and collides across message types. The fix uses DialogueId for lookup so replies +// are always appended to the correct existing dialogue. +public class ReplyToDownlinkCommandHandlerTests +{ + [Fact] + public async Task Handle_AppendsUplinkToExistingDialogue_NotCreatingNew() + { + // Regression for issue #34: controller replying to a pilot downlink must append + // to the existing dialogue, not start a new one. + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink( + 7, + null, + "UAL123", + CpdlcDownlinkResponseType.ResponseRequired, + AlertType.None, + "REQUEST DESCENT FL350", + clock.UtcNow()); + await dialogueRepository.Add(dialogue, CancellationToken.None); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + var command = new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 7, CpdlcUplinkResponseType.WilcoUnable, "DESCEND FL350"); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert - only one dialogue exists (no new dialogue was created) + var allDialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(allDialogues); + + // Assert - the uplink was added to the existing dialogue + Assert.Equal(2, allDialogues[0].Messages.Count); + Assert.IsType(allDialogues[0].Messages[0]); + Assert.IsType(allDialogues[0].Messages[1]); + } + + [Fact] + public async Task Handle_UplinkMessageReferenceMatchesDownlinkId() + { + // The outgoing uplink must carry the downlink's MessageId as its MessageReference + // so the aircraft knows which of its messages is being replied to. + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink(7, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); + await dialogueRepository.Add(dialogue, CancellationToken.None); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + // Act + var result = await handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 7, CpdlcUplinkResponseType.WilcoUnable, "DESCEND FL350"), + CancellationToken.None); + + // Assert + Assert.Equal(7, result.UplinkMessage.MessageReference); + } + + [Fact] + public async Task Handle_UsesDialogueId_WhenMultipleDialoguesHaveSameDownlinkMessageId() + { + // Regression for issue #34: the old code searched by MessageId, which is not globally + // unique. Two dialogues for the same aircraft can legitimately have messages with the + // same MessageId. The fix uses DialogueId for unambiguous lookup. + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + // Both dialogues have a downlink with MessageId=1 (aircraft re-used the same ID) + var dialogueA = new Dialogue("UAL123"); + dialogueA.AddDownlink(1, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST CLIMB FL350", clock.UtcNow()); + await dialogueRepository.Add(dialogueA, CancellationToken.None); + + var dialogueB = new Dialogue("UAL123"); + dialogueB.AddDownlink(1, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST DESCENT FL250", clock.UtcNow()); + await dialogueRepository.Add(dialogueB, CancellationToken.None); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + // Controller explicitly replies to dialogueA + await handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", dialogueA.Id, 1, CpdlcUplinkResponseType.WilcoUnable, "CLIMB FL350"), + CancellationToken.None); + + // Assert - only dialogueA received the uplink + Assert.Equal(2, dialogueA.Messages.Count); + Assert.Single(dialogueB.Messages); // dialogueB is untouched + } + + [Fact] + public async Task Handle_SendsUplinkViaAcarsClient() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink(7, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); + await dialogueRepository.Add(dialogue, CancellationToken.None); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + // Act + await handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 7, CpdlcUplinkResponseType.WilcoUnable, "DESCEND FL350"), + CancellationToken.None); + + // Assert + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.SentMessages); + Assert.Equal("UAL123", client.SentMessages[0].Recipient); + Assert.Equal("DESCEND FL350", client.SentMessages[0].Content); + Assert.Equal(7, client.SentMessages[0].MessageReference); + } + + [Fact] + public async Task Handle_PublishesDialogueChangedNotification() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + aircraft.RequestLogon(clock.UtcNow()); + aircraft.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); + + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink(7, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST DESCENT FL350", clock.UtcNow()); + await dialogueRepository.Add(dialogue, CancellationToken.None); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + // Act + await handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 7, CpdlcUplinkResponseType.WilcoUnable, "DESCEND FL350"), + CancellationToken.None); + + // Assert + await mediator.Received(1).Publish( + Arg.Is(n => n.Dialogue.Id == dialogue.Id), + Arg.Any()); + } + + [Fact] + public async Task Handle_ThrowsWhenDialogueNotFound() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + // Act & Assert + await Assert.ThrowsAsync(() => + handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", Guid.NewGuid(), 1, CpdlcUplinkResponseType.NoResponse, "ROGER"), + CancellationToken.None)); + } + + [Fact] + public async Task Handle_ThrowsWhenAircraftNotConnected() + { + // Arrange + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + + // Dialogue exists but aircraft is not in the repository + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink(1, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST DESCENT", clock.UtcNow()); + await dialogueRepository.Add(dialogue, CancellationToken.None); + + var handler = new ReplyToDownlinkCommandHandler( + aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); + + // Act & Assert + await Assert.ThrowsAsync(() => + handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 1, CpdlcUplinkResponseType.NoResponse, "ROGER"), + CancellationToken.None)); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs index 860cbc0..436546b 100644 --- a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs @@ -162,9 +162,9 @@ public async Task Handle_CreatesNewDialogue_ForUplinkWithNoReference() } [Fact] - public async Task Handle_AppendsToExistingDialogue_ForUplinkWithReference() + public async Task Handle_AlwaysCreatesNewDialogue_EvenWithReference() { - // Arrange + // Arrange - SendUplinkCommand is system-internal only; it always creates a new dialogue var clientManager = new TestClientManager(); var messageIdProvider = new TestMessageIdProvider(); var dialogueRepository = new TestDialogueRepository(); @@ -177,19 +177,6 @@ public async Task Handle_AppendsToExistingDialogue_ForUplinkWithReference() aircraft.AcceptLogon(clock.UtcNow()); await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); - // Create existing dialogue with a downlink - var downlink = new DownlinkMessage( - 5, - null, - "UAL123", - CpdlcDownlinkResponseType.ResponseRequired, - AlertType.None, - "REQUEST CLIMB FL410", - clock.UtcNow()); - - var existingDialogue = new Dialogue("UAL123", downlink); - await dialogueRepository.Add(existingDialogue, CancellationToken.None); - var mediator = Substitute.For(); var handler = new SendUplinkCommandHandler( aircraftRepository, @@ -201,7 +188,7 @@ public async Task Handle_AppendsToExistingDialogue_ForUplinkWithReference() Logger.None); var command = new SendUplinkCommand( - "BN-TSN_FSS", + "SYSTEM", "UAL123", 5, CpdlcUplinkResponseType.NoResponse, @@ -210,15 +197,12 @@ public async Task Handle_AppendsToExistingDialogue_ForUplinkWithReference() // Act var result = await handler.Handle(command, CancellationToken.None); - // Assert - var dialogue = await dialogueRepository.FindOpenDialogueByUplink( - "UAL123", - 5, - CancellationToken.None); - - Assert.NotNull(dialogue); - Assert.Equal(2, dialogue.Messages.Count); - Assert.Contains(result.UplinkMessage, dialogue.Messages); + // Assert - a new dialogue is created containing only the uplink + var allDialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(allDialogues); + Assert.Single(allDialogues[0].Messages); + Assert.Equal(result.UplinkMessage, allDialogues[0].Messages[0]); + Assert.Equal(5, result.UplinkMessage.MessageReference); } [Fact] diff --git a/source/CPDLCServer.Tests/Handlers/TerminateConnectionOnDialogueClosedHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/TerminateConnectionOnDialogueClosedHandlerTests.cs index d1855d1..aafa77e 100644 --- a/source/CPDLCServer.Tests/Handlers/TerminateConnectionOnDialogueClosedHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/TerminateConnectionOnDialogueClosedHandlerTests.cs @@ -30,7 +30,8 @@ public async Task Handle_ClosedDialogueWithEndService_TerminatesConnection() Logger.None); // Create a closed dialogue with END SERVICE - var endServiceMessage = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -39,7 +40,6 @@ public async Task Handle_ClosedDialogueWithEndService_TerminatesConnection() AlertType.None, "END SERVICE", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", endServiceMessage); var notification = new DialogueChangedNotification(dialogue); @@ -75,7 +75,8 @@ public async Task Handle_OpenDialogueWithEndService_DoesNotTerminateConnection() Logger.None); // Create an open dialogue with END SERVICE (requires response) - var endServiceMessage = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -84,7 +85,6 @@ public async Task Handle_OpenDialogueWithEndService_DoesNotTerminateConnection() AlertType.None, "END SERVICE. CONTACT AUCKLAND ON 123.450.", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", endServiceMessage); var notification = new DialogueChangedNotification(dialogue); @@ -118,7 +118,8 @@ public async Task Handle_ClosedDialogueWithoutEndService_DoesNotTerminateConnect Logger.None); // Create a closed dialogue WITHOUT END SERVICE - var message = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -127,7 +128,6 @@ public async Task Handle_ClosedDialogueWithoutEndService_DoesNotTerminateConnect AlertType.None, "LOGON ACCEPTED", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", message); var notification = new DialogueChangedNotification(dialogue); @@ -161,8 +161,10 @@ public async Task Handle_ClosedDialogueWithMultipleMessages_TerminatesWhenEndSer Logger.None); // Create a dialogue with multiple messages including END SERVICE + var dialogue = new Dialogue("UAL123"); + // Pilot request - var downlink = new DownlinkMessage( + dialogue.AddDownlink( 1, null, "UAL123", @@ -170,10 +172,9 @@ public async Task Handle_ClosedDialogueWithMultipleMessages_TerminatesWhenEndSer AlertType.None, "REQUEST CLIMB TO FL370", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", downlink); // Controller response with END SERVICE - var uplink = new UplinkMessage( + dialogue.AddUplink( 2, 1, "UAL123", @@ -182,10 +183,9 @@ public async Task Handle_ClosedDialogueWithMultipleMessages_TerminatesWhenEndSer AlertType.None, "CLEARED TO CLIMB TO FL370. CONTACT MELBOURNE 123.45. END SERVICE", clock.UtcNow()); - dialogue.AddMessage(uplink); // Pilot response (closes the dialogue) - var response = new DownlinkMessage( + dialogue.AddDownlink( 3, 2, "UAL123", @@ -193,7 +193,6 @@ public async Task Handle_ClosedDialogueWithMultipleMessages_TerminatesWhenEndSer AlertType.None, "WILCO", clock.UtcNow()); - dialogue.AddMessage(response); var notification = new DialogueChangedNotification(dialogue); @@ -225,7 +224,8 @@ public async Task Handle_AircraftConnectionNotFound_LogsWarningAndDoesNotCrash() Logger.None); // Create a closed dialogue with END SERVICE - var endServiceMessage = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -234,7 +234,6 @@ public async Task Handle_AircraftConnectionNotFound_LogsWarningAndDoesNotCrash() AlertType.None, "END SERVICE", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", endServiceMessage); var notification = new DialogueChangedNotification(dialogue); @@ -268,7 +267,8 @@ public async Task Handle_AircraftConnectionNotCDA_DoesNotTerminate() Logger.None); // Create a closed dialogue with END SERVICE - var endServiceMessage = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -277,7 +277,6 @@ public async Task Handle_AircraftConnectionNotCDA_DoesNotTerminate() AlertType.None, "END SERVICE", clock.UtcNow()); - var dialogue = new Dialogue("UAL123", endServiceMessage); var notification = new DialogueChangedNotification(dialogue); diff --git a/source/CPDLCServer.Tests/Handlers/TransmitPendingNdaUplinksCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/TransmitPendingNdaUplinksCommandHandlerTests.cs index 00e01af..5b94a78 100644 --- a/source/CPDLCServer.Tests/Handlers/TransmitPendingNdaUplinksCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/TransmitPendingNdaUplinksCommandHandlerTests.cs @@ -49,7 +49,7 @@ public async Task Handle_WhenNoNextDataAuthority_DoesNotSendMessage() await handler.Handle(new TransmitPendingNdaUplinksCommand(), CancellationToken.None); // Assert - await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Fact] @@ -79,7 +79,7 @@ public async Task Handle_WhenHandoffMessageAlreadySent_DoesNotSendMessage() await handler.Handle(new TransmitPendingNdaUplinksCommand(), CancellationToken.None); // Assert - await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Fact] @@ -110,7 +110,7 @@ public async Task Handle_WhenOutsideNotificationWindow_DoesNotSendMessage() await handler.Handle(new TransmitPendingNdaUplinksCommand(), CancellationToken.None); // Assert - await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Fact] @@ -141,7 +141,7 @@ public async Task Handle_WhenNdaAtsuIsOffline_DoesNotSendMessage() await handler.Handle(new TransmitPendingNdaUplinksCommand(), CancellationToken.None); // Assert - await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Fact] @@ -173,7 +173,7 @@ public async Task Handle_WhenWithinNotificationWindow_SendsNextDataAuthorityMess // Assert await mediator.Received(1).Send( - Arg.Is(c => + Arg.Is(c => c.Sender == "YBBB" && c.Recipient == "UAL123" && c.Content == "NEXT DATA AUTHORITY @YMMM@" && diff --git a/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs b/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs index bde5e94..4dbc9bf 100644 --- a/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs +++ b/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs @@ -6,9 +6,9 @@ namespace CPDLCServer.Tests.Mocks; public class TestAcarsClient(string stationId) : IAcarsClient { - private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly Channel _channel = Channel.CreateUnbounded(); - public ChannelReader MessageReader => _channel.Reader; + public ChannelReader MessageReader => _channel.Reader; public string StationId => stationId; public List Connections { get; } = []; diff --git a/source/CPDLCServer.Tests/Model/DialogueTests.cs b/source/CPDLCServer.Tests/Model/DialogueTests.cs index 8d44a6a..5fc2035 100644 --- a/source/CPDLCServer.Tests/Model/DialogueTests.cs +++ b/source/CPDLCServer.Tests/Model/DialogueTests.cs @@ -9,7 +9,10 @@ public void Constructor_AddsFirstMessage() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + + // Act + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -18,9 +21,6 @@ public void Constructor_AddsFirstMessage() "REQUEST CLIMB FL410", time); - // Act - var dialogue = new Dialogue("UAL123", downlink); - // Assert Assert.Single(dialogue.Messages); Assert.Equal(downlink, dialogue.Messages[0]); @@ -31,7 +31,10 @@ public void Constructor_SetsOpenedTimeToFirstMessageTime() { // Arrange var time = new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero); - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + + // Act + dialogue.AddDownlink( 1, null, "UAL123", @@ -40,9 +43,6 @@ public void Constructor_SetsOpenedTimeToFirstMessageTime() "REQUEST CLIMB FL410", time); - // Act - var dialogue = new Dialogue("UAL123", downlink); - // Assert Assert.Equal(time, dialogue.Opened); } @@ -51,18 +51,17 @@ public void Constructor_SetsOpenedTimeToFirstMessageTime() public void Constructor_SetsCallsignFromParameter() { // Arrange - var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + + // Act + dialogue.AddDownlink( 1, null, "UAL123", CpdlcDownlinkResponseType.ResponseRequired, AlertType.None, "REQUEST CLIMB FL410", - time); - - // Act - var dialogue = new Dialogue("UAL123", downlink); + DateTimeOffset.UtcNow); // Assert Assert.Equal("UAL123", dialogue.AircraftCallsign); @@ -73,7 +72,8 @@ public void AddMessage_UplinkResponseClosesReferencedDownlink() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -82,9 +82,8 @@ public void AddMessage_UplinkResponseClosesReferencedDownlink() "REQUEST CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", downlink); - - var uplink = new UplinkMessage( + // Act + dialogue.AddUplink( 2, 1, // References downlink message 1 "UAL123", @@ -94,9 +93,6 @@ public void AddMessage_UplinkResponseClosesReferencedDownlink() "UNABLE", time.AddSeconds(10)); - // Act - dialogue.AddMessage(uplink); - // Assert Assert.True(downlink.IsClosed); Assert.True(downlink.IsAcknowledged); @@ -107,7 +103,8 @@ public void AddMessage_DownlinkResponseClosesReferencedUplink() { // Arrange var time = DateTimeOffset.UtcNow; - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -117,9 +114,8 @@ public void AddMessage_DownlinkResponseClosesReferencedUplink() "CLIMB TO FL410", time); - var dialogue = new Dialogue("UAL123", uplink); - - var downlink = new DownlinkMessage( + // Act + dialogue.AddDownlink( 2, 1, // References uplink message 1 "UAL123", @@ -128,9 +124,6 @@ public void AddMessage_DownlinkResponseClosesReferencedUplink() "WILCO", time.AddSeconds(10)); - // Act - dialogue.AddMessage(downlink); - // Assert Assert.True(uplink.IsClosed); Assert.True(uplink.IsAcknowledged); @@ -141,7 +134,8 @@ public void AddMessage_StandbyDownlinkDoesNotCloseUplink() { // Arrange var time = DateTimeOffset.UtcNow; - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -151,9 +145,8 @@ public void AddMessage_StandbyDownlinkDoesNotCloseUplink() "CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", uplink); - - var standbyDownlink = new DownlinkMessage( + // Act + dialogue.AddDownlink( 2, 1, // References uplink message 1 "UAL123", @@ -162,9 +155,6 @@ public void AddMessage_StandbyDownlinkDoesNotCloseUplink() "STANDBY", time.AddSeconds(10)); - // Act - dialogue.AddMessage(standbyDownlink); - // Assert Assert.False(uplink.IsClosed); } @@ -174,7 +164,8 @@ public void AddMessage_StandbyUplinkDoesNotCloseDownlink() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -183,9 +174,8 @@ public void AddMessage_StandbyUplinkDoesNotCloseDownlink() "REQUEST CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", downlink); - - var standbyUplink = new UplinkMessage( + // Act + dialogue.AddUplink( 2, 1, // References downlink message 1 "UAL123", @@ -195,9 +185,6 @@ public void AddMessage_StandbyUplinkDoesNotCloseDownlink() "STANDBY", time.AddSeconds(10)); - // Act - dialogue.AddMessage(standbyUplink); - // Assert Assert.False(downlink.IsClosed); Assert.False(downlink.IsAcknowledged); @@ -208,7 +195,8 @@ public void AddMessage_RequestDeferredUplinkDoesNotCloseDownlink() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -217,9 +205,8 @@ public void AddMessage_RequestDeferredUplinkDoesNotCloseDownlink() "REQUEST CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", downlink); - - var deferredUplink = new UplinkMessage( + // Act + dialogue.AddUplink( 2, 1, // References downlink message 1 "UAL123", @@ -229,9 +216,6 @@ public void AddMessage_RequestDeferredUplinkDoesNotCloseDownlink() "REQUEST DEFERRED", time.AddSeconds(10)); - // Act - dialogue.AddMessage(deferredUplink); - // Assert Assert.False(downlink.IsClosed); Assert.False(downlink.IsAcknowledged); @@ -242,7 +226,8 @@ public void Dialogue_ClosesWhenAllMessagesAreClosed() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink( 1, null, "UAL123", @@ -251,9 +236,7 @@ public void Dialogue_ClosesWhenAllMessagesAreClosed() "REQUEST CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", downlink); - - var uplink = new UplinkMessage( + var uplink = dialogue.AddUplink( 2, 1, // References downlink message 1 "UAL123", @@ -263,9 +246,6 @@ public void Dialogue_ClosesWhenAllMessagesAreClosed() "UNABLE", time.AddSeconds(10)); - // Act - dialogue.AddMessage(uplink); - // Assert Assert.True(dialogue.IsClosed); Assert.NotNull(dialogue.Closed); @@ -277,7 +257,8 @@ public void Dialogue_RemainsOpenWhenSomeMessagesAreOpen() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -286,9 +267,7 @@ public void Dialogue_RemainsOpenWhenSomeMessagesAreOpen() "REQUEST CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", downlink); - - var uplink = new UplinkMessage( + var uplink = dialogue.AddUplink( 2, 1, // References downlink message 1 "UAL123", @@ -298,9 +277,6 @@ public void Dialogue_RemainsOpenWhenSomeMessagesAreOpen() "CLIMB TO FL410", time.AddSeconds(10)); - // Act - dialogue.AddMessage(uplink); - // Assert - downlink is closed by uplink response, but uplink requires response so stays open Assert.True(downlink.IsClosed); Assert.False(uplink.IsClosed); @@ -313,7 +289,10 @@ public void Dialogue_MessageRequiringNoResponseIsSelfClosing() { // Arrange var time = DateTimeOffset.UtcNow; - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + + // Act + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -322,9 +301,6 @@ public void Dialogue_MessageRequiringNoResponseIsSelfClosing() "POSITION REPORT", time); - // Act - var dialogue = new Dialogue("UAL123", downlink); - // Assert Assert.True(downlink.IsClosed); Assert.True(dialogue.IsClosed); @@ -337,8 +313,10 @@ public void Dialogue_MultipleMessagesAndResponses() // Arrange - Simulate a realistic CPDLC exchange var time = DateTimeOffset.UtcNow; + var dialogue = new Dialogue("UAL123"); + // Pilot requests climb - var downlink1 = new DownlinkMessage( + var downlink1 = dialogue.AddDownlink( 1, null, "UAL123", @@ -347,11 +325,10 @@ public void Dialogue_MultipleMessagesAndResponses() "REQUEST CLIMB FL410", time); - var dialogue = new Dialogue("UAL123", downlink1); Assert.False(dialogue.IsClosed); // Dialogue open - downlink awaiting response // Controller sends STANDBY - var uplink1 = new UplinkMessage( + dialogue.AddUplink( 2, 1, "UAL123", @@ -361,12 +338,11 @@ public void Dialogue_MultipleMessagesAndResponses() "STANDBY", time.AddSeconds(5)); - dialogue.AddMessage(uplink1); Assert.False(downlink1.IsClosed); // STANDBY doesn't close the request Assert.False(dialogue.IsClosed); // Instruction issued - var uplink2 = new UplinkMessage( + var uplink2 = dialogue.AddUplink( 3, 1, "UAL123", @@ -376,13 +352,12 @@ public void Dialogue_MultipleMessagesAndResponses() "CLIMG TO FL410", time.AddSeconds(30)); - dialogue.AddMessage(uplink2); Assert.True(downlink1.IsClosed); // Now the request is closed Assert.True(downlink1.IsAcknowledged); Assert.False(dialogue.IsClosed); // But dialogue still open - uplink needs response // Pilot acknowledges - var downlink2 = new DownlinkMessage( + var downlink2 = dialogue.AddDownlink( 4, 3, "UAL123", @@ -391,7 +366,6 @@ public void Dialogue_MultipleMessagesAndResponses() "WILCO", time.AddSeconds(40)); - dialogue.AddMessage(downlink2); Assert.True(uplink2.IsClosed); Assert.True(uplink2.IsAcknowledged); Assert.True(dialogue.IsClosed); // All messages closed, dialogue closes @@ -404,8 +378,10 @@ public void Dialogue_LogonRequestAndAcceptanceClosesImmediately() // Arrange var time = DateTimeOffset.UtcNow; + var dialogue = new Dialogue("UAL123"); + // Pilot sends logon request - var logonRequest = new DownlinkMessage( + var logonRequest = dialogue.AddDownlink( 1, null, "UAL123", @@ -414,11 +390,10 @@ public void Dialogue_LogonRequestAndAcceptanceClosesImmediately() "REQUEST LOGON", time); - var dialogue = new Dialogue("UAL123", logonRequest); Assert.False(dialogue.IsClosed); // Dialogue open - waiting for response // System sends LOGON ACCEPTED (NoResponse type, so self-closing) - var logonAccepted = new UplinkMessage( + var logonAccepted = dialogue.AddUplink( 2, 1, // References logon request "UAL123", @@ -428,9 +403,6 @@ public void Dialogue_LogonRequestAndAcceptanceClosesImmediately() "LOGON ACCEPTED", time.AddSeconds(1)); - // Act - dialogue.AddMessage(logonAccepted); - // Assert Assert.True(logonRequest.IsClosed); // Request is closed by the acceptance Assert.True(logonRequest.IsAcknowledged); // Request is auto-acknowledged diff --git a/source/CPDLCServer.Tests/Services/MessageMonitorServiceTests.cs b/source/CPDLCServer.Tests/Services/MessageMonitorServiceTests.cs index 47bc6d7..6688e8e 100644 --- a/source/CPDLCServer.Tests/Services/MessageMonitorServiceTests.cs +++ b/source/CPDLCServer.Tests/Services/MessageMonitorServiceTests.cs @@ -20,7 +20,8 @@ public async Task CheckForTimeouts_MarksPilotLate_WhenUplinkTimesOut() clock.SetUtcNow(startTime); // Create an uplink that requires a response - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -29,8 +30,6 @@ public async Task CheckForTimeouts_MarksPilotLate_WhenUplinkTimesOut() AlertType.None, "CLIMB TO FL410", startTime); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -57,7 +56,8 @@ public async Task CheckForTimeouts_DoesNotMarkPilotLate_WhenTimeoutNotReached() var startTime = DateTimeOffset.UtcNow; clock.SetUtcNow(startTime); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -66,8 +66,6 @@ public async Task CheckForTimeouts_DoesNotMarkPilotLate_WhenTimeoutNotReached() AlertType.None, "CLIMB TO FL410", startTime); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -93,7 +91,8 @@ public async Task CheckForTimeouts_IgnoresNoResponseUplinks() clock.SetUtcNow(startTime); // Create an uplink that doesn't require a response - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -102,8 +101,6 @@ public async Task CheckForTimeouts_IgnoresNoResponseUplinks() AlertType.None, "ROGER", startTime); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -129,7 +126,8 @@ public async Task CheckForTimeouts_MarksControllerLate_WhenDownlinkTimesOut() clock.SetUtcNow(startTime); // Create a downlink that requires a response - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + var downlink = dialogue.AddDownlink( 1, null, "UAL123", @@ -137,8 +135,6 @@ public async Task CheckForTimeouts_MarksControllerLate_WhenDownlinkTimesOut() AlertType.None, "REQUEST CLIMB FL410", startTime); - - var dialogue = new Dialogue("UAL123", downlink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -165,7 +161,8 @@ public async Task CheckForTimeouts_IgnoresClosedMessages() var startTime = DateTimeOffset.UtcNow; clock.SetUtcNow(startTime); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + var uplink = dialogue.AddUplink( 1, null, "UAL123", @@ -178,7 +175,6 @@ public async Task CheckForTimeouts_IgnoresClosedMessages() // Close the message uplink.Close(startTime); - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -204,7 +200,8 @@ public async Task CheckForTimeouts_IgnoresClosedDialogues() clock.SetUtcNow(startTime); // Create a self-closing uplink (NoResponse) - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -213,8 +210,6 @@ public async Task CheckForTimeouts_IgnoresClosedDialogues() AlertType.None, "ROGER", startTime); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -240,7 +235,8 @@ public async Task ArchiveCompletedDialogues_ArchivesDialogue_WhenAllMessagesAckn clock.SetUtcNow(startTime); // Create a closed dialogue with acknowledged message - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -249,8 +245,6 @@ public async Task ArchiveCompletedDialogues_ArchivesDialogue_WhenAllMessagesAckn AlertType.None, "ROGER", startTime); // NoResponse uplinks are self-closing and auto-acknowledged - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -277,7 +271,8 @@ public async Task ArchiveCompletedDialogues_DoesNotArchive_WhenDelayNotPassed() var startTime = DateTimeOffset.UtcNow; clock.SetUtcNow(startTime); - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -286,8 +281,6 @@ public async Task ArchiveCompletedDialogues_DoesNotArchive_WhenDelayNotPassed() AlertType.None, "ROGER", startTime); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -313,7 +306,8 @@ public async Task ArchiveCompletedDialogues_IgnoresOpenDialogues() clock.SetUtcNow(startTime); // Create an open dialogue (requires response) - var uplink = new UplinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddUplink( 1, null, "UAL123", @@ -322,8 +316,6 @@ public async Task ArchiveCompletedDialogues_IgnoresOpenDialogues() AlertType.None, "CLIMB TO FL410", startTime); - - var dialogue = new Dialogue("UAL123", uplink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -350,7 +342,8 @@ public async Task ArchiveCompletedDialogues_IgnoresDialoguesWithoutAcknowledgeme clock.SetUtcNow(startTime); // Create a downlink that's closed but not acknowledged - var downlink = new DownlinkMessage( + var dialogue = new Dialogue("UAL123"); + dialogue.AddDownlink( 1, null, "UAL123", @@ -358,8 +351,6 @@ public async Task ArchiveCompletedDialogues_IgnoresDialoguesWithoutAcknowledgeme AlertType.None, "WILCO", startTime); - - var dialogue = new Dialogue("UAL123", downlink); await repository.Add(dialogue, CancellationToken.None); var service = new MessageMonitorService(repository, clock, publisher, Logger.None); @@ -386,11 +377,11 @@ public async Task CheckForTimeouts_HandlesMultipleDialogues() clock.SetUtcNow(startTime); // Create multiple dialogues with different aircraft - var uplink1 = new UplinkMessage(1, null, "UAL123", "BN-TSN_FSS", CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "CLIMB", startTime); - var uplink2 = new UplinkMessage(2, null, "DAL456", "BN-TSN_FSS", CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "DESCEND", startTime); + var dialogue1 = new Dialogue("UAL123"); + var uplink1 = dialogue1.AddUplink(1, null, "UAL123", "BN-TSN_FSS", CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "CLIMB", startTime); - var dialogue1 = new Dialogue("UAL123", uplink1); - var dialogue2 = new Dialogue("DAL456", uplink2); + var dialogue2 = new Dialogue("DAL456"); + var uplink2 = dialogue2.AddUplink(2, null, "DAL456", "BN-TSN_FSS", CpdlcUplinkResponseType.WilcoUnable, AlertType.None, "DESCEND", startTime); await repository.Add(dialogue1, CancellationToken.None); await repository.Add(dialogue2, CancellationToken.None); From 7e60bf9fef8c5a2e0af0012df303a3690ee9ee9d Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 23 May 2026 22:29:27 +1000 Subject: [PATCH 6/7] refactor: Remove SendUplinkCommand --- .../Server/SignalRConnectionManager.cs | 10 +- .../ViewModels/CurrentMessagesViewModel.cs | 32 +- .../BeginDialogueCommandHandlerTests.cs | 10 +- ...ownlinkReceivedNotificationHandlerTests.cs | 211 ++++++++---- .../Handlers/LogonCommandHandlerTests.cs | 209 +++++++---- .../ReplyToDownlinkCommandHandlerTests.cs | 5 +- .../Handlers/SendUplinkCommandHandlerTests.cs | 325 ------------------ .../Handlers/BeginDialogueCommandHandler.cs | 6 +- .../DownlinkReceivedNotificationHandler.cs | 306 ++++++++++------- .../Handlers/LogonCommandHandler.cs | 76 ++-- .../Handlers/ReplyToDownlinkCommandHandler.cs | 6 +- .../Handlers/SendUplinkCommandHandler.cs | 66 ---- source/CPDLCServer/Hubs/ControllerHub.cs | 12 +- .../Messages/BeginDialogueCommand.cs | 2 +- source/CPDLCServer/Messages/LogonCommand.cs | 8 +- .../Messages/ReplyToDownlinkCommand.cs | 2 +- .../CPDLCServer/Messages/SendUplinkCommand.cs | 14 - 17 files changed, 578 insertions(+), 722 deletions(-) delete mode 100644 source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs delete mode 100644 source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs delete mode 100644 source/CPDLCServer/Messages/SendUplinkCommand.cs diff --git a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs index fc0741e..42e94dc 100644 --- a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs +++ b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs @@ -144,14 +144,13 @@ Func WithCancellationToken(Func BeginDialogue( - string recipient, + public async Task BeginDialogue(string recipient, CpdlcUplinkResponseType responseType, string content, CancellationToken cancellationToken) { var connection = GetConnectedOrThrow(); - return await connection.InvokeAsync( + await connection.InvokeAsync( "BeginDialogue", recipient, responseType, @@ -159,15 +158,14 @@ public async Task BeginDialogue( cancellationToken: cancellationToken); } - public async Task ReplyToDownlink( - Guid dialogueId, + public async Task ReplyToDownlink(Guid dialogueId, int downlinkMessageId, CpdlcUplinkResponseType responseType, string content, CancellationToken cancellationToken) { var connection = GetConnectedOrThrow(); - return await connection.InvokeAsync( + await connection.InvokeAsync( "ReplyToDownlink", dialogueId, downlinkMessageId, diff --git a/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs b/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs index c7c0225..5a28830 100644 --- a/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs @@ -77,6 +77,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 1: Regular Uplink/Downlink messages (not acknowledged) var group1Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST1", @@ -90,6 +91,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) }; var group1Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST1", @@ -112,6 +114,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 3: Urgent messages (not acknowledged) var group3Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.High, Recipient = "TEST3", @@ -125,6 +128,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) }; var group3Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.High, Sender = "TEST3", @@ -147,6 +151,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 5: Closed messages (not acknowledged) var group5Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST5", @@ -161,6 +166,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) }; var group5Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST5", @@ -184,6 +190,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 7: Special Closed messages (not acknowledged) var group7Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST7", @@ -206,6 +213,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 9: Failed messages (not acknowledged) var group9Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST9", @@ -227,6 +235,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 11: Pilot Late messages (not acknowledged) var group11Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST11", @@ -248,6 +257,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 13: Controller Late messages (not acknowledged) var group13Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST13", @@ -266,6 +276,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 15: Special Closed Timeout (pilot late special - Normal video) var group15Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST15", @@ -288,6 +299,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 16: Overflow message (shows asterisk prefix) var group16Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST16", @@ -306,6 +318,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 2: Regular messages (acknowledged) var group2Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST2", @@ -320,6 +333,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) }; var group2Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST2", @@ -343,6 +357,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 4: Urgent messages (acknowledged) var group4Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.High, Recipient = "TEST4", @@ -357,6 +372,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) }; var group4Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.High, Sender = "TEST4", @@ -380,6 +396,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 6: Closed messages (acknowledged) var group6Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST6", @@ -395,6 +412,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) }; var group6Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST6", @@ -419,6 +437,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 8: Special Closed messages (acknowledged) var group8Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST8", @@ -442,6 +461,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 10: Failed messages (acknowledged) var group10Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST10", @@ -464,6 +484,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 12: Pilot Late messages (acknowledged) var group12Uplink = new UplinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Recipient = "TEST12", @@ -486,6 +507,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) // Group 14: Controller Late messages (acknowledged) var group14Downlink = new DownlinkMessageDto { + DialogueId = Guid.NewGuid(), MessageId = messageId++, AlertType = AlertType.None, Sender = "TEST14", @@ -569,7 +591,7 @@ async Task SendStandby(CurrentMessageViewModel currentMessageViewModel) if (currentMessageViewModel.Message is not DownlinkMessageDto downlink) return; - await _mediator.Send(new SendStandbyUplinkRequest(downlink.MessageId, downlink.Sender)); + await _mediator.Send(new SendStandbyUplinkRequest(downlink.DialogueId, downlink.MessageId, downlink.Sender)); } catch (Exception ex) { @@ -585,7 +607,7 @@ async Task SendDeferred(CurrentMessageViewModel currentMessageViewModel) if (currentMessageViewModel.Message is not DownlinkMessageDto downlink) return; - await _mediator.Send(new SendDeferredUplinkRequest(downlink.MessageId, downlink.Sender)); + await _mediator.Send(new SendDeferredUplinkRequest(downlink.DialogueId, downlink.MessageId, downlink.Sender)); } catch (Exception ex) { @@ -601,7 +623,7 @@ async Task SendUnable(CurrentMessageViewModel currentMessageViewModel) if (currentMessageViewModel.Message is not DownlinkMessageDto downlink) return; - await _mediator.Send(new SendUnableUplinkRequest(downlink.MessageId, downlink.Sender)); + await _mediator.Send(new SendUnableUplinkRequest(downlink.DialogueId, downlink.MessageId, downlink.Sender)); } catch (Exception ex) { @@ -617,7 +639,7 @@ async Task SendUnableDueTraffic(CurrentMessageViewModel currentMessageViewModel) if (currentMessageViewModel.Message is not DownlinkMessageDto downlink) return; - await _mediator.Send(new SendUnableUplinkRequest(downlink.MessageId, downlink.Sender, Reason: "DUE TO TRAFFIC")); + await _mediator.Send(new SendUnableUplinkRequest(downlink.DialogueId, downlink.MessageId, downlink.Sender, Reason: "DUE TO TRAFFIC")); } catch (Exception ex) { @@ -633,7 +655,7 @@ async Task SendUnableDueAirspace(CurrentMessageViewModel currentMessageViewModel if (currentMessageViewModel.Message is not DownlinkMessageDto downlink) return; - await _mediator.Send(new SendUnableUplinkRequest(downlink.MessageId, downlink.Sender, Reason: "DUE TO AIRSPACE RESTRICTION")); + await _mediator.Send(new SendUnableUplinkRequest(downlink.DialogueId, downlink.MessageId, downlink.Sender, Reason: "DUE TO AIRSPACE RESTRICTION")); } catch (Exception ex) { diff --git a/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs index de7bf73..ef57462 100644 --- a/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs @@ -32,14 +32,14 @@ public async Task Handle_CreatesNewDialogue() var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); // Act - var result = await handler.Handle(command, CancellationToken.None); + await handler.Handle(command, CancellationToken.None); // Assert var dialogues = await dialogueRepository.All(CancellationToken.None); Assert.Single(dialogues); Assert.Equal("UAL123", dialogues[0].AircraftCallsign); Assert.Single(dialogues[0].Messages); - Assert.Equal(result.UplinkMessage, dialogues[0].Messages[0]); + Assert.IsType(dialogues[0].Messages[0]); } [Fact] @@ -65,10 +65,12 @@ public async Task Handle_UplinkHasNullMessageReference() var command = new BeginDialogueCommand("BN-TSN_FSS", "UAL123", CpdlcUplinkResponseType.WilcoUnable, "CLIMB TO FL410"); // Act - var result = await handler.Handle(command, CancellationToken.None); + await handler.Handle(command, CancellationToken.None); // Assert - Assert.Null(result.UplinkMessage.MessageReference); + var dialogues = await dialogueRepository.All(CancellationToken.None); + var uplink = Assert.IsType(dialogues[0].Messages[0]); + Assert.Null(uplink.MessageReference); } [Fact] diff --git a/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs index d50d5b8..41fe689 100644 --- a/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/DownlinkReceivedNotificationHandlerTests.cs @@ -12,6 +12,26 @@ namespace CPDLCServer.Tests.Handlers; public class DownlinkReceivedNotificationHandlerTests { + static DownlinkReceivedNotificationHandler BuildHandler( + TestAircraftRepository aircraftRepository, + TestClientManager clientManager, + TestMessageIdProvider messageIdProvider, + IMediator mediator, + TestClock clock, + TestControllerRepository controllerRepository, + TestDialogueRepository dialogueRepository, + IHubContext hubContext) => + new( + aircraftRepository, + clientManager, + messageIdProvider, + mediator, + clock, + controllerRepository, + dialogueRepository, + hubContext, + Logger.None); + [Fact] public async Task Handle_PublishesDialogueChangedNotification() { @@ -45,15 +65,10 @@ public async Task Handle_PublishesDialogueChangedNotification() hubContext.Clients.Clients(Arg.Any>()).Returns(clientProxy); var dialogueRepository = new TestDialogueRepository(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, @@ -109,15 +124,10 @@ public async Task Handle_StillCreatesDialogueWhenNoControllersMatch() hubContext.Clients.Clients(Arg.Any>()).Returns(clientProxy); var dialogueRepository = new TestDialogueRepository(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, @@ -169,15 +179,10 @@ public async Task Handle_PromotesAircraftToCurrentDataAuthorityOnFirstDownlink() hubContext.Clients.Clients(Arg.Any>()).Returns(clientProxy); var dialogueRepository = new TestDialogueRepository(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, @@ -248,15 +253,10 @@ public async Task Handle_DoesNotPromoteToCurrentDataAuthority_WhenNotCurrentData hubContext.Clients.Clients(Arg.Any>()).Returns(clientProxy); var dialogueRepository = new TestDialogueRepository(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, @@ -321,15 +321,10 @@ public async Task Handle_UpdatesLastSeen() clock.SetUtcNow(expectedLastSeen); var dialogueRepository = new TestDialogueRepository(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, @@ -370,15 +365,10 @@ public async Task Handle_CreatesNewDialogue_ForDownlinkWithNoReference() var dialogueRepository = new TestDialogueRepository(); var mediator = Substitute.For(); var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftRepository, - mediator, - clock, - controllerRepository, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, @@ -419,6 +409,8 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() var dialogueRepository = new TestDialogueRepository(); var mediator = Substitute.For(); var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); // Create existing dialogue with an uplink var existingDialogue = new Dialogue("UAL123"); @@ -433,14 +425,7 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() clock.UtcNow()); await dialogueRepository.Add(existingDialogue, CancellationToken.None); - var handler = new DownlinkReceivedNotificationHandler( - aircraftRepository, - mediator, - clock, - controllerRepository, - dialogueRepository, - hubContext, - Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 10, @@ -483,9 +468,10 @@ public async Task Handle_FallsBackToNewDialogue_WhenNoOpenDialogueMatchesReferen var dialogueRepository = new TestDialogueRepository(); var mediator = Substitute.For(); var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftRepository, mediator, clock, controllerRepository, dialogueRepository, hubContext, Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); // Downlink has a MessageReference (uplink ID=99) but no open dialogue exists for it var downlink = new ReceivedDownlink( @@ -532,6 +518,8 @@ public async Task Handle_DoesNotAppendToClosedDialogue_WhenAircraftReusesMessage var dialogueRepository = new TestDialogueRepository(); var mediator = Substitute.For(); var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); // Prior session: uplink ID=5 was sent and fully replied to (dialogue is now closed) var priorDialogue = new Dialogue("UAL123"); @@ -547,8 +535,7 @@ public async Task Handle_DoesNotAppendToClosedDialogue_WhenAircraftReusesMessage Assert.True(priorDialogue.IsClosed); - var handler = new DownlinkReceivedNotificationHandler( - aircraftRepository, mediator, clock, controllerRepository, dialogueRepository, hubContext, Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); // New session: aircraft re-uses MessageReference=5 (same uplink ID as in the old session) var downlink = new ReceivedDownlink( @@ -591,9 +578,10 @@ public async Task Handle_LogoffMessage_TerminatesConnectionAndCreatesDialogue() var dialogueRepository = new TestDialogueRepository(); var mediator = Substitute.For(); var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftRepository, mediator, clock, controllerRepository, dialogueRepository, hubContext, Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -619,4 +607,103 @@ await mediator.Received(1).Send( await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); } + + [Fact] + public async Task Handle_LogonRequest_DispatchesLogonCommand() + { + // Branch 1: a logon request is forwarded to LogonCommandHandler verbatim and the + // notification handler must not touch the dialogue repository itself. + // Arrange + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); + + var downlink = new ReceivedDownlink( + 1, + null, + "QFA1", + CpdlcDownlinkResponseType.NoResponse, + AlertType.None, + "REQUEST LOGON", + clock.UtcNow()); + + var notification = new DownlinkReceivedNotification("hoppies-ybbb", "YBBB", downlink); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - LogonCommand dispatched with downlink primitives + AcarsClientId + await mediator.Received(1).Send( + Arg.Is(c => + c.DownlinkId == 1 && + c.DownlinkMessageReference == null && + c.Callsign == "QFA1" && + c.DownlinkContent == "REQUEST LOGON" && + c.AcarsClientId == "hoppies-ybbb"), + Arg.Any()); + + // Notification handler must not have created a dialogue itself - LogonCommandHandler owns it. + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Empty(dialogues); + } + + [Fact] + public async Task Handle_UnknownAircraft_CreatesDialogueAndTransmitsError() + { + // Branch 2: a downlink arrives from an aircraft we have no connection for. The handler + // creates a dialogue (downlink + system error uplink) and transmits the error via ACARS. + // Arrange + var clock = new TestClock(); + var aircraftRepository = new TestAircraftRepository(); + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var mediator = Substitute.For(); + var hubContext = Substitute.For>(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); + + var downlink = new ReceivedDownlink( + 7, + null, + "GHOST1", + CpdlcDownlinkResponseType.NoResponse, + AlertType.None, + "REQUEST DESCENT", + clock.UtcNow()); + + var notification = new DownlinkReceivedNotification("hoppies-ybbb", "YBBB", downlink); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - dialogue contains both the original downlink and the error uplink + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("GHOST1", dialogues[0].AircraftCallsign); + Assert.Equal(2, dialogues[0].Messages.Count); + + var receivedDownlink = Assert.IsType(dialogues[0].Messages[0]); + Assert.Equal(7, receivedDownlink.MessageId); + + var errorUplink = Assert.IsType(dialogues[0].Messages[1]); + Assert.Equal("ERROR. CONNECTION NOT ESTABLISHED.", errorUplink.Content); + Assert.Equal("GHOST1", errorUplink.Recipient); + Assert.Equal(7, errorUplink.MessageReference); + + // Assert - error uplink transmitted via the ACARS client + var client = (TestAcarsClient)await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.SentMessages); + Assert.Equal("ERROR. CONNECTION NOT ESTABLISHED.", client.SentMessages[0].Content); + + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); + } } diff --git a/source/CPDLCServer.Tests/Handlers/LogonCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/LogonCommandHandlerTests.cs index b61f4c1..3eb5c33 100644 --- a/source/CPDLCServer.Tests/Handlers/LogonCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/LogonCommandHandlerTests.cs @@ -1,4 +1,4 @@ -using CPDLCServer.Handlers; +using CPDLCServer.Handlers; using CPDLCServer.Messages; using CPDLCServer.Model; using CPDLCServer.Tests.Mocks; @@ -10,36 +10,81 @@ namespace CPDLCServer.Tests.Handlers; public class LogonCommandHandlerTests { + static LogonCommand BuildLogonCommand(TestClock clock) => new( + DownlinkId: 1, + DownlinkMessageReference: null, + Callsign: "QFA1", + DownlinkResponseType: CpdlcDownlinkResponseType.NoResponse, + DownlinkAlertType: AlertType.None, + DownlinkContent: "REQUEST LOGON", + DownlinkReceived: clock.UtcNow(), + AcarsClientId: "hoppies-ybbb"); + + static LogonCommandHandler BuildHandler( + TestClientManager clientManager, + TestAircraftRepository aircraftRepository, + TestControllerRepository controllerRepository, + TestDialogueRepository dialogueRepository, + TestMessageIdProvider messageIdProvider, + TestClock clock, + IMediator mediator) => + new( + clientManager, + aircraftRepository, + controllerRepository, + dialogueRepository, + messageIdProvider, + clock, + mediator, + Logger.None); + [Fact] public async Task Handle_NoATC_RejectsLogon() { // Arrange var clock = new TestClock(); + var clientManager = new TestClientManager(); var aircraftRepository = new TestAircraftRepository(); var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); var mediator = Substitute.For(); - var command = new LogonCommand(1, "QFA1", "hoppies-ybbb"); + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); - var handler = new LogonCommandHandler( - new TestClientManager(), - aircraftRepository, - controllerRepository, - clock, - mediator, - Logger.None); + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert - rejection uplink transmitted via the ACARS client + var client = (TestAcarsClient)await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.SentMessages); + Assert.Equal("LOGON REJECTED. NO ATS AVBL.", client.SentMessages[0].Content); + Assert.Equal("QFA1", client.SentMessages[0].Recipient); + Assert.Equal(1, client.SentMessages[0].MessageReference); + } + + [Fact] + public async Task Handle_NoATC_DoesNotTrackAircraft() + { + // Arrange + var clock = new TestClock(); + var clientManager = new TestClientManager(); + var aircraftRepository = new TestAircraftRepository(); + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); + var mediator = Substitute.For(); + + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); // Act await handler.Handle(command, CancellationToken.None); - // Assert - await mediator.Received(1).Send(new SendUplinkCommand( - "SYSTEM", - "QFA1", - 1, - CpdlcUplinkResponseType.NoResponse, - "LOGON REJECTED. NO ATS AVBL."), - Arg.Any()); + // Assert - the in-flight aircraft entry is rolled back + var tracked = await aircraftRepository.All(CancellationToken.None); + Assert.Empty(tracked); } [Fact] @@ -47,8 +92,11 @@ public async Task Handle_NewConnection_AcceptsLogon() { // Arrange var clock = new TestClock(); + var clientManager = new TestClientManager(); var aircraftRepository = new TestAircraftRepository(); var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); var mediator = Substitute.For(); var controller = new ControllerInfo( @@ -57,30 +105,20 @@ public async Task Handle_NewConnection_AcceptsLogon() "YBBB", "BN-TSN_FSS", "1234567"); - await controllerRepository.Add(controller, CancellationToken.None); - var command = new LogonCommand(1, "QFA1", "hoppies-ybbb"); - - var handler = new LogonCommandHandler( - new TestClientManager(), - aircraftRepository, - controllerRepository, - clock, - mediator, - Logger.None); + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); // Act await handler.Handle(command, CancellationToken.None); - // Assert - await mediator.Received(1).Send(new SendUplinkCommand( - "SYSTEM", - "QFA1", - 1, - CpdlcUplinkResponseType.NoResponse, - "LOGON ACCEPTED"), - Arg.Any()); + // Assert - acceptance uplink transmitted + var client = (TestAcarsClient)await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.SentMessages); + Assert.Equal("LOGON ACCEPTED", client.SentMessages[0].Content); + Assert.Equal("QFA1", client.SentMessages[0].Recipient); + Assert.Equal(1, client.SentMessages[0].MessageReference); } [Fact] @@ -88,8 +126,11 @@ public async Task Handle_NewConnection_TracksConnection() { // Arrange var clock = new TestClock(); + var clientManager = new TestClientManager(); var aircraftRepository = new TestAircraftRepository(); var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); var mediator = Substitute.For(); var controller = new ControllerInfo( @@ -98,18 +139,10 @@ public async Task Handle_NewConnection_TracksConnection() "YBBB", "BN-TSN_FSS", "1234567"); - await controllerRepository.Add(controller, CancellationToken.None); - var command = new LogonCommand(1, "QFA1", "hoppies-ybbb"); - - var handler = new LogonCommandHandler( - new TestClientManager(), - aircraftRepository, - controllerRepository, - clock, - mediator, - Logger.None); + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); // Act await handler.Handle(command, CancellationToken.None); @@ -124,12 +157,15 @@ public async Task Handle_NewConnection_TracksConnection() } [Fact] - public async Task Handle_ExistingConnection_AcceptsLogon_AndDoesNotDuplicateConnection() + public async Task Handle_NewConnection_PersistsDialogueWithDownlinkAndUplink() { // Arrange var clock = new TestClock(); + var clientManager = new TestClientManager(); var aircraftRepository = new TestAircraftRepository(); var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); var mediator = Substitute.For(); var controller = new ControllerInfo( @@ -138,30 +174,66 @@ public async Task Handle_ExistingConnection_AcceptsLogon_AndDoesNotDuplicateConn "YBBB", "BN-TSN_FSS", "1234567"); + await controllerRepository.Add(controller, CancellationToken.None); + + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert - dialogue contains the original LOGON REQUEST downlink and the LOGON ACCEPTED uplink + var dialogues = await dialogueRepository.All(CancellationToken.None); + Assert.Single(dialogues); + Assert.Equal("QFA1", dialogues[0].AircraftCallsign); + Assert.Equal(2, dialogues[0].Messages.Count); + + var downlink = Assert.IsType(dialogues[0].Messages[0]); + Assert.Equal("REQUEST LOGON", downlink.Content); + Assert.Equal(1, downlink.MessageId); + + var uplink = Assert.IsType(dialogues[0].Messages[1]); + Assert.Equal("LOGON ACCEPTED", uplink.Content); + Assert.Equal(1, uplink.MessageReference); + + await mediator.Received(1).Publish(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ExistingConnection_AcceptsLogon_AndDoesNotDuplicateConnection() + { + // Arrange + var clock = new TestClock(); + var clientManager = new TestClientManager(); + var aircraftRepository = new TestAircraftRepository(); + var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); + var mediator = Substitute.For(); + var controller = new ControllerInfo( + Guid.NewGuid(), + "Connection-1", + "YBBB", + "BN-TSN_FSS", + "1234567"); await controllerRepository.Add(controller, CancellationToken.None); - var command = new LogonCommand(1, "QFA1", "hoppies-ybbb"); + var existing = new AircraftConnection("QFA1", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); + existing.RequestLogon(clock.UtcNow()); + existing.AcceptLogon(clock.UtcNow()); + await aircraftRepository.Add(new("QFA1", "hoppies-ybbb"), existing, CancellationToken.None); - var handler = new LogonCommandHandler( - new TestClientManager(), - aircraftRepository, - controllerRepository, - clock, - mediator, - Logger.None); + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); // Act await handler.Handle(command, CancellationToken.None); // Assert - await mediator.Received(1).Send(new SendUplinkCommand( - "SYSTEM", - "QFA1", - 1, - CpdlcUplinkResponseType.NoResponse, - "LOGON ACCEPTED"), - Arg.Any()); + var client = (TestAcarsClient)await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.SentMessages); + Assert.Equal("LOGON ACCEPTED", client.SentMessages[0].Content); var allTrackedAircraft = await aircraftRepository.All(CancellationToken.None); Assert.Single(allTrackedAircraft); @@ -173,8 +245,11 @@ public async Task Handle_NotifiesATC() { // Arrange var clock = new TestClock(); + var clientManager = new TestClientManager(); var aircraftRepository = new TestAircraftRepository(); var controllerRepository = new TestControllerRepository(); + var dialogueRepository = new TestDialogueRepository(); + var messageIdProvider = new TestMessageIdProvider(); var mediator = Substitute.For(); var controller = new ControllerInfo( @@ -183,18 +258,10 @@ public async Task Handle_NotifiesATC() "YBBB", "BN-TSN_FSS", "1234567"); - await controllerRepository.Add(controller, CancellationToken.None); - var command = new LogonCommand(1, "QFA1", "hoppies-ybbb"); - - var handler = new LogonCommandHandler( - new TestClientManager(), - aircraftRepository, - controllerRepository, - clock, - mediator, - Logger.None); + var command = BuildLogonCommand(clock); + var handler = BuildHandler(clientManager, aircraftRepository, controllerRepository, dialogueRepository, messageIdProvider, clock, mediator); // Act await handler.Handle(command, CancellationToken.None); diff --git a/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs index 934025d..ac2a2fc 100644 --- a/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs @@ -87,12 +87,13 @@ public async Task Handle_UplinkMessageReferenceMatchesDownlinkId() aircraftRepository, clientManager, messageIdProvider, dialogueRepository, mediator, clock, Logger.None); // Act - var result = await handler.Handle( + await handler.Handle( new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 7, CpdlcUplinkResponseType.WilcoUnable, "DESCEND FL350"), CancellationToken.None); // Assert - Assert.Equal(7, result.UplinkMessage.MessageReference); + var uplink = dialogue.Messages.OfType().Single(); + Assert.Equal(7, uplink.MessageReference); } [Fact] diff --git a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs deleted file mode 100644 index 436546b..0000000 --- a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs +++ /dev/null @@ -1,325 +0,0 @@ -using CPDLCServer.Handlers; -using CPDLCServer.Messages; -using CPDLCServer.Model; -using CPDLCServer.Tests.Mocks; -using MediatR; -using NSubstitute; -using Serilog.Core; - -namespace CPDLCServer.Tests.Handlers; - -public class SendUplinkCommandHandlerTests -{ - [Fact] - public async Task Handle_SendsMessageToAcarsClient() - { - // Arrange - var clientManager = new TestClientManager(); - var messageIdProvider = new TestMessageIdProvider(); - var dialogueRepository = new TestDialogueRepository(); - var mediator = Substitute.For(); - var clock = new TestClock(); - var aircraftRepository = new TestAircraftRepository(); - - // Create aircraft connection - var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft.RequestLogon(clock.UtcNow()); - aircraft.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); - - var handler = new SendUplinkCommandHandler( - aircraftRepository, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "BN-TSN_FSS", - "UAL123", - null, - CpdlcUplinkResponseType.WilcoUnable, - "CLIMB TO @FL410@"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Equal(1, result.UplinkMessage.MessageId); - - var client = await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); - var testClient = (TestAcarsClient)client; - Assert.Single(testClient.SentMessages); - - var sentMessage = Assert.IsType(testClient.SentMessages[0]); - Assert.Equal(1, sentMessage.MessageId); - Assert.Equal("UAL123", sentMessage.Recipient); - Assert.Null(sentMessage.MessageReference); - Assert.Equal(CpdlcUplinkResponseType.WilcoUnable, sentMessage.ResponseType); - Assert.Equal("CLIMB TO @FL410@", sentMessage.Content); - } - - [Fact] - public async Task Handle_SendsReplyToAcarsClient() - { - // Arrange - var clientManager = new TestClientManager(); - var messageIdProvider = new TestMessageIdProvider(); - var dialogueRepository = new TestDialogueRepository(); - var mediator = Substitute.For(); - var clock = new TestClock(); - var aircraftRepository = new TestAircraftRepository(); - - // Create aircraft connection - var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft.RequestLogon(clock.UtcNow()); - aircraft.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); - - var handler = new SendUplinkCommandHandler( - aircraftRepository, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "BN-TSN_FSS", - "UAL123", - 5, - CpdlcUplinkResponseType.NoResponse, - "ROGER"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Equal(1, result.UplinkMessage.MessageId); - - var client = await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); - var testClient = (TestAcarsClient)client; - Assert.Single(testClient.SentMessages); - - var sentMessage = Assert.IsType(testClient.SentMessages[0]); - Assert.Equal(1, sentMessage.MessageId); - Assert.Equal("UAL123", sentMessage.Recipient); - Assert.Equal(5, sentMessage.MessageReference); - Assert.Equal(CpdlcUplinkResponseType.NoResponse, sentMessage.ResponseType); - Assert.Equal("ROGER", sentMessage.Content); - } - - [Fact] - public async Task Handle_CreatesNewDialogue_ForUplinkWithNoReference() - { - // Arrange - var clientManager = new TestClientManager(); - var messageIdProvider = new TestMessageIdProvider(); - var dialogueRepository = new TestDialogueRepository(); - var mediator = Substitute.For(); - var clock = new TestClock(); - var aircraftRepository = new TestAircraftRepository(); - - // Create aircraft connection - var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft.RequestLogon(clock.UtcNow()); - aircraft.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); - - var handler = new SendUplinkCommandHandler( - aircraftRepository, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "BN-TSN_FSS", - "UAL123", - null, - CpdlcUplinkResponseType.WilcoUnable, - "CLIMB TO FL410"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - var dialogue = await dialogueRepository.FindOpenDialogueByUplink( - "UAL123", - result.UplinkMessage.MessageId, - CancellationToken.None); - - Assert.NotNull(dialogue); - Assert.Single(dialogue.Messages); - Assert.Equal(result.UplinkMessage, dialogue.Messages[0]); - } - - [Fact] - public async Task Handle_AlwaysCreatesNewDialogue_EvenWithReference() - { - // Arrange - SendUplinkCommand is system-internal only; it always creates a new dialogue - var clientManager = new TestClientManager(); - var messageIdProvider = new TestMessageIdProvider(); - var dialogueRepository = new TestDialogueRepository(); - var clock = new TestClock(); - var aircraftRepository = new TestAircraftRepository(); - - // Create aircraft connection - var aircraft = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft.RequestLogon(clock.UtcNow()); - aircraft.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft.Callsign, aircraft.AcarsClientId), aircraft, CancellationToken.None); - - var mediator = Substitute.For(); - var handler = new SendUplinkCommandHandler( - aircraftRepository, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "SYSTEM", - "UAL123", - 5, - CpdlcUplinkResponseType.NoResponse, - "UNABLE"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - a new dialogue is created containing only the uplink - var allDialogues = await dialogueRepository.All(CancellationToken.None); - Assert.Single(allDialogues); - Assert.Single(allDialogues[0].Messages); - Assert.Equal(result.UplinkMessage, allDialogues[0].Messages[0]); - Assert.Equal(5, result.UplinkMessage.MessageReference); - } - - [Fact] - public async Task Handle_RoutesToCorrectAcarsClient() - { - // Arrange - var clientManager = new TestClientManager(); - var messageIdProvider = new TestMessageIdProvider(); - var dialogueRepository = new TestDialogueRepository(); - var mediator = Substitute.For(); - var clock = new TestClock(); - var aircraftRepository = new TestAircraftRepository(); - - // Create aircraft connection - var aircraft1 = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft1.RequestLogon(clock.UtcNow()); - aircraft1.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft1.Callsign, aircraft1.AcarsClientId), aircraft1, CancellationToken.None); - - var aircraft2 = new AircraftConnection("UAL456", "hoppies-ymmm", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft2.RequestLogon(clock.UtcNow()); - aircraft2.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft2.Callsign, aircraft2.AcarsClientId), aircraft2, CancellationToken.None); - - var handler = new SendUplinkCommandHandler( - aircraftRepository, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "BN-TSN_FSS", - "UAL456", - 5, - CpdlcUplinkResponseType.NoResponse, - "ROGER"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Equal(1, result.UplinkMessage.MessageId); - - // No messages should've been sent through YBBB - var ybbbClient = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); - Assert.Empty(ybbbClient.SentMessages); - - // Aircraft is connected to YMMM, so the YMMM client should send the message - var ymmmClient = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ymmm", CancellationToken.None); - Assert.Single(ymmmClient.SentMessages); - - var sentMessage = Assert.IsType(ymmmClient.SentMessages[0]); - Assert.Equal(1, sentMessage.MessageId); - Assert.Equal("UAL456", sentMessage.Recipient); - Assert.Equal(5, sentMessage.MessageReference); - Assert.Equal(CpdlcUplinkResponseType.NoResponse, sentMessage.ResponseType); - Assert.Equal("ROGER", sentMessage.Content); - } - - [Fact] - public async Task Handle_WithMultipleConnections_PrefersCurrentDataAuthority() - { - // Arrange - var clientManager = new TestClientManager(); - var messageIdProvider = new TestMessageIdProvider(); - var dialogueRepository = new TestDialogueRepository(); - var mediator = Substitute.For(); - var clock = new TestClock(); - var aircraftRepository = new TestAircraftRepository(); - - // Create aircraft connection - var aircraft1 = new AircraftConnection("UAL123", "hoppies-ybbb", "YBBB", DataAuthorityState.CurrentDataAuthority); - aircraft1.RequestLogon(clock.UtcNow()); - aircraft1.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft1.Callsign, aircraft1.AcarsClientId), aircraft1, CancellationToken.None); - - var aircraft2 = new AircraftConnection("UAL123", "hoppies-ymmm", "YMMM", DataAuthorityState.NextDataAuthority); - aircraft2.RequestLogon(clock.UtcNow()); - aircraft2.AcceptLogon(clock.UtcNow()); - await aircraftRepository.Add(new(aircraft2.Callsign, aircraft2.AcarsClientId), aircraft2, CancellationToken.None); - - var handler = new SendUplinkCommandHandler( - aircraftRepository, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "BN-TSN_FSS", - "UAL123", - null, - CpdlcUplinkResponseType.WilcoUnable, - "CLIMB TO @FL410@"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Equal(1, result.UplinkMessage.MessageId); - - var client = await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); - var testClient = (TestAcarsClient)client; - Assert.Single(testClient.SentMessages); - - var sentMessage = Assert.IsType(testClient.SentMessages[0]); - Assert.Equal(1, sentMessage.MessageId); - Assert.Equal("UAL123", sentMessage.Recipient); - Assert.Null(sentMessage.MessageReference); - Assert.Equal(CpdlcUplinkResponseType.WilcoUnable, sentMessage.ResponseType); - Assert.Equal("CLIMB TO @FL410@", sentMessage.Content); - } -} diff --git a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs index a72227a..e172077 100644 --- a/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs +++ b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs @@ -16,9 +16,9 @@ public class BeginDialogueCommandHandler( IMediator mediator, IClock clock, ILogger logger) - : IRequestHandler + : IRequestHandler { - public async Task Handle(BeginDialogueCommand request, CancellationToken cancellationToken) + public async Task Handle(BeginDialogueCommand request, CancellationToken cancellationToken) { logger.Information("Beginning new dialogue with {Recipient} (Content: {Content})", request.Recipient, request.Content); @@ -56,7 +56,5 @@ public async Task Handle(BeginDialogueCommand request, Cancell await client.Send(uplinkMessage, cancellationToken); logger.Information("Sent CPDLC message from {Sender} to {Recipient}", request.Sender, request.Recipient); - - return new SendUplinkResult(uplinkMessage); } } diff --git a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs index bfada43..644ec5d 100644 --- a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs +++ b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs @@ -1,120 +1,186 @@ -using CPDLCServer.Hubs; -using CPDLCServer.Infrastructure; -using CPDLCServer.Messages; -using CPDLCServer.Model; -using CPDLCServer.Persistence; -using MediatR; -using Microsoft.AspNetCore.SignalR; - -namespace CPDLCServer.Handlers; - -public class DownlinkReceivedNotificationHandler( - IAircraftRepository aircraftRepository, - IMediator mediator, - IClock clock, - IControllerRepository controllerRepository, - IDialogueRepository dialogueRepository, - IHubContext hubContext, - ILogger logger) - : INotificationHandler -{ - public async Task Handle(DownlinkReceivedNotification notification, CancellationToken cancellationToken) - { - var downlink = notification.Downlink; - logger.Information("Downlink message received from {Callsign}", downlink.Sender); - - // Branch 1: Logon request - if (ControlMessages.IsLogonRequest(downlink)) - { - var dialogue = new Dialogue(downlink.Sender); - dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); - await dialogueRepository.Add(dialogue, cancellationToken); - - await mediator.Send(new LogonCommand(downlink.MessageId, downlink.Sender, notification.AcarsClientId), cancellationToken); - return; - } - - var aircraftConnection = await aircraftRepository.Find( - new(downlink.Sender, notification.AcarsClientId), - cancellationToken); - - // Branch 2: Unknown aircraft - if (aircraftConnection is null) - { - logger.Information("{Callsign} is not known by this ATSU, sending error uplink", downlink.Sender); - await mediator.Send( - new SendUplinkCommand("SYSTEM", downlink.Sender, downlink.MessageId, CpdlcUplinkResponseType.NoResponse, "ERROR. CONNECTION NOT ESTABLISHED."), - cancellationToken); - return; - } - - // Intercept logoff messages - if (ControlMessages.IsLogoffNotice(downlink)) - { - await mediator.Send(new TerminateConnectionRequest(downlink.Sender, notification.AcarsClientId), cancellationToken); - // Allow these to flow through to the controller - } - - // Promote aircraft to CurrentDataAuthority on first downlink, unless - // the aircraft explicitly indicates we are not the current data authority - if (aircraftConnection.DataAuthorityState == DataAuthorityState.NextDataAuthority && - !ControlMessages.IsNotCurrentDataAuthority(downlink)) - { - aircraftConnection.PromoteToCurrentDataAuthority(); - logger.Information("{Callsign} promoted to CurrentDataAuthority", downlink.Sender); - - var controllers = await controllerRepository.All(cancellationToken); - if (controllers.Any()) - { - await hubContext.Clients - .Clients(controllers.Select(c => c.ConnectionId)) - .SendAsync( - "AircraftConnectionUpdated", - new Contracts.AircraftConnectionDto( - aircraftConnection.Callsign, - notification.StationId, - DialogueConverter.ToDto(aircraftConnection.DataAuthorityState)), - cancellationToken); - - logger.Information( - "Notified {ControllerCount} controller(s) that aircraft {Callsign} was promoted to CurrentDataAuthority", - controllers.Length, - aircraftConnection.Callsign); - } - } - - aircraftConnection.LogLastSeen(clock.UtcNow()); - - // Branch 3: Aircraft replies to an uplink - if (downlink.MessageReference.HasValue) - { - var dialogue = await dialogueRepository.FindOpenDialogueByUplink(downlink.Sender, downlink.MessageReference.Value, cancellationToken); - if (dialogue is not null) - { - dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); - logger.Information("Downlink from {Callsign} appended to dialogue {DialogueId}", downlink.Sender, dialogue.Id); - } - else - { - logger.Warning("No open dialogue found for uplink reference {MessageReference} from {Callsign} - starting new dialogue", - downlink.MessageReference.Value, downlink.Sender); - dialogue = new Dialogue(downlink.Sender); - dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); - await dialogueRepository.Add(dialogue, cancellationToken); - } - - await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); - return; - } - - // Branch 4: Aircraft initiates a new dialogue - { - var dialogue = new Dialogue(downlink.Sender); - dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); - await dialogueRepository.Add(dialogue, cancellationToken); - logger.Information("Dialogue {DialogueId} created for downlink from {Callsign}", dialogue.Id, downlink.Sender); - - await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); - } - } -} +using CPDLCServer.Clients; +using CPDLCServer.Hubs; +using CPDLCServer.Infrastructure; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Services; +using MediatR; +using Microsoft.AspNetCore.SignalR; + +namespace CPDLCServer.Handlers; + +public class DownlinkReceivedNotificationHandler( + IAircraftRepository aircraftRepository, + IClientManager clientManager, + IMessageIdProvider messageIdProvider, + IMediator mediator, + IClock clock, + IControllerRepository controllerRepository, + IDialogueRepository dialogueRepository, + IHubContext hubContext, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(DownlinkReceivedNotification notification, CancellationToken cancellationToken) + { + var downlink = notification.Downlink; + logger.Information("Downlink message received from {Callsign}", downlink.Sender); + + // Scenario 1: Logon request + if (ControlMessages.IsLogonRequest(downlink)) + { + await mediator.Send( + new LogonCommand( + downlink.MessageId, + downlink.MessageReference, + downlink.Sender, + downlink.ResponseType, + downlink.AlertType, + downlink.Content, + downlink.Received, + notification.AcarsClientId), + cancellationToken); + return; + } + + var aircraftConnection = await aircraftRepository.Find( + new(downlink.Sender, notification.AcarsClientId), + cancellationToken); + + // Scenario 2: Unknown aircraft + if (aircraftConnection is null) + { + logger.Information("{Callsign} is not known by this ATSU, sending error uplink", downlink.Sender); + await SendUnknownAircraftError(notification, downlink, cancellationToken); + return; + } + + // Intercept logoff messages + if (ControlMessages.IsLogoffNotice(downlink)) + { + await mediator.Send(new TerminateConnectionRequest(downlink.Sender, notification.AcarsClientId), cancellationToken); + // Allow these to flow through to the controller + } + + // Promote aircraft to CurrentDataAuthority on first downlink, unless + // the aircraft explicitly indicates we are not the current data authority + if (aircraftConnection.DataAuthorityState == DataAuthorityState.NextDataAuthority && + !ControlMessages.IsNotCurrentDataAuthority(downlink)) + { + aircraftConnection.PromoteToCurrentDataAuthority(); + logger.Information("{Callsign} promoted to CurrentDataAuthority", downlink.Sender); + + var controllers = await controllerRepository.All(cancellationToken); + if (controllers.Any()) + { + await hubContext.Clients + .Clients(controllers.Select(c => c.ConnectionId)) + .SendAsync( + "AircraftConnectionUpdated", + new Contracts.AircraftConnectionDto( + aircraftConnection.Callsign, + notification.StationId, + DialogueConverter.ToDto(aircraftConnection.DataAuthorityState)), + cancellationToken); + + logger.Information( + "Notified {ControllerCount} controller(s) that aircraft {Callsign} was promoted to CurrentDataAuthority", + controllers.Length, + aircraftConnection.Callsign); + } + } + + aircraftConnection.LogLastSeen(clock.UtcNow()); + + // Scenario 3: Aircraft replies to an uplink + if (downlink.MessageReference.HasValue) + { + var dialogue = await dialogueRepository.FindOpenDialogueByUplink( + downlink.Sender, + downlink.MessageReference.Value, + cancellationToken); + if (dialogue is not null) + { + dialogue.AddDownlink(downlink.MessageId, downlink.MessageReference, downlink.Sender, downlink.ResponseType, downlink.AlertType, downlink.Content, downlink.Received); + logger.Information("Downlink from {Callsign} appended to dialogue {DialogueId}", downlink.Sender, dialogue.Id); + } + else + { + // TODO: This could lead to a bug if the Aircraft thinks we're still in the original message chain. + // Consider returning an error here instead. + logger.Warning("No open dialogue found for uplink reference {MessageReference} from {Callsign} - starting new dialogue", downlink.MessageReference.Value, downlink.Sender); + dialogue = new Dialogue(downlink.Sender); + dialogue.AddDownlink( + downlink.MessageId, + downlink.MessageReference, + downlink.Sender, + downlink.ResponseType, + downlink.AlertType, + downlink.Content, + downlink.Received); + + await dialogueRepository.Add(dialogue, cancellationToken); + } + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + } + else // Scenario 4: Aircraft initiates a new dialogue + { + var dialogue = new Dialogue(downlink.Sender); + dialogue.AddDownlink( + downlink.MessageId, + downlink.MessageReference, + downlink.Sender, + downlink.ResponseType, + downlink.AlertType, + downlink.Content, + downlink.Received); + + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for downlink from {Callsign}", dialogue.Id, downlink.Sender); + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + } + } + + async Task SendUnknownAircraftError( + DownlinkReceivedNotification notification, + ReceivedDownlink downlink, + CancellationToken cancellationToken) + { + var dialogue = new Dialogue(downlink.Sender); + dialogue.AddDownlink( + downlink.MessageId, + downlink.MessageReference, + downlink.Sender, + downlink.ResponseType, + downlink.AlertType, + downlink.Content, + downlink.Received); + + var messageId = await messageIdProvider.GetNextMessageId( + notification.AcarsClientId, + downlink.Sender, + cancellationToken); + + var uplinkMessage = dialogue.AddUplink( + messageId, + downlink.MessageId, + downlink.Sender, + "SYSTEM", + CpdlcUplinkResponseType.NoResponse, + AlertType.None, + "ERROR. CONNECTION NOT ESTABLISHED.", + clock.UtcNow()); + + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for unknown aircraft {Callsign}", dialogue.Id, downlink.Sender); + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + + var client = await clientManager.GetAcarsClient(notification.AcarsClientId, cancellationToken); + await client.Send(uplinkMessage, cancellationToken); + logger.Information("Sent CPDLC message from SYSTEM to {Callsign}", downlink.Sender); + } +} diff --git a/source/CPDLCServer/Handlers/LogonCommandHandler.cs b/source/CPDLCServer/Handlers/LogonCommandHandler.cs index 70fc60f..7e83caa 100644 --- a/source/CPDLCServer/Handlers/LogonCommandHandler.cs +++ b/source/CPDLCServer/Handlers/LogonCommandHandler.cs @@ -3,16 +3,17 @@ using CPDLCServer.Messages; using CPDLCServer.Model; using CPDLCServer.Persistence; +using CPDLCServer.Services; using MediatR; namespace CPDLCServer.Handlers; -// TODO: Unit tests - public class LogonCommandHandler( IClientManager clientManager, IAircraftRepository aircraftRepository, IControllerRepository controllerRepository, + IDialogueRepository dialogueRepository, + IMessageIdProvider messageIdProvider, IClock clock, IMediator mediator, ILogger logger) @@ -32,14 +33,7 @@ public async Task Handle(LogonCommand request, CancellationToken cancellationTok { logger.Verbose("Duplicate connection request received from {Callsign} on {ClientId}. Accepting the request.", request.Callsign, request.AcarsClientId); - await mediator.Send( - new SendUplinkCommand( - "SYSTEM", - request.Callsign, - request.DownlinkId, - CpdlcUplinkResponseType.NoResponse, - "LOGON ACCEPTED"), - cancellationToken); + await Reply(client, request, "LOGON ACCEPTED", cancellationToken); return; } @@ -61,16 +55,9 @@ await aircraftRepository.Add( var activeControllers = await controllerRepository.All(cancellationToken); if (activeControllers.Length == 0) { - logger.Information("New connection request received from {Callsign} on {ClientId}, but no ATC is online. Rejecting the request.", request.Callsign, request.AcarsClientId); - - await mediator.Send( - new SendUplinkCommand( - "SYSTEM", - request.Callsign, - ReplyToDownlinkId: request.DownlinkId, - CpdlcUplinkResponseType.NoResponse, - "LOGON REJECTED. NO ATS AVBL."), - cancellationToken); + logger.Information("New connection request received from {Callsign} on {ClientId}, but no ATS is online. Rejecting the request.", request.Callsign, request.AcarsClientId); + + await Reply(client, request, "LOGON REJECTED. NO ATS AVBL.", cancellationToken); await aircraftRepository.Remove( new AircraftKey(request.Callsign, request.AcarsClientId), @@ -84,14 +71,7 @@ await aircraftRepository.Remove( // Immediately accept it for now aircraft.AcceptLogon(clock.UtcNow()); - await mediator.Send( - new SendUplinkCommand( - "SYSTEM", - request.Callsign, - request.DownlinkId, - CpdlcUplinkResponseType.NoResponse, - "LOGON ACCEPTED"), - cancellationToken); + await Reply(client, request, "LOGON ACCEPTED", cancellationToken); await mediator.Publish( new AircraftConnectionEstablished( @@ -101,4 +81,44 @@ await mediator.Publish( aircraft.DataAuthorityState), cancellationToken); } + + async Task Reply( + IAcarsClient client, + LogonCommand request, + string content, + CancellationToken cancellationToken) + { + var dialogue = new Dialogue(request.Callsign); + dialogue.AddDownlink( + request.DownlinkId, + request.DownlinkMessageReference, + request.Callsign, + request.DownlinkResponseType, + request.DownlinkAlertType, + request.DownlinkContent, + request.DownlinkReceived); + + var messageId = await messageIdProvider.GetNextMessageId( + request.AcarsClientId, + request.Callsign, + cancellationToken); + + var uplinkMessage = dialogue.AddUplink( + messageId, + request.DownlinkId, + request.Callsign, + "SYSTEM", + CpdlcUplinkResponseType.NoResponse, + AlertType.None, + content, + clock.UtcNow()); + + await dialogueRepository.Add(dialogue, cancellationToken); + logger.Information("Dialogue {DialogueId} created for logon reply to {Callsign}", dialogue.Id, request.Callsign); + + await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); + + await client.Send(uplinkMessage, cancellationToken); + logger.Information("Sent CPDLC message from SYSTEM to {Callsign}", request.Callsign); + } } diff --git a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs index 6b8f719..8dfc823 100644 --- a/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs +++ b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs @@ -16,9 +16,9 @@ public class ReplyToDownlinkCommandHandler( IMediator mediator, IClock clock, ILogger logger) - : IRequestHandler + : IRequestHandler { - public async Task Handle(ReplyToDownlinkCommand request, CancellationToken cancellationToken) + public async Task Handle(ReplyToDownlinkCommand request, CancellationToken cancellationToken) { var dialogue = await dialogueRepository.FindById(request.DialogueId, cancellationToken); if (dialogue is null) @@ -59,7 +59,5 @@ public async Task Handle(ReplyToDownlinkCommand request, Cance await client.Send(uplinkMessage, cancellationToken); logger.Information("Sent CPDLC message from {Sender} to {Callsign}", request.Sender, callsign); - - return new SendUplinkResult(uplinkMessage); } } diff --git a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs b/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs deleted file mode 100644 index 17fb612..0000000 --- a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs +++ /dev/null @@ -1,66 +0,0 @@ -using CPDLCServer.Clients; -using CPDLCServer.Infrastructure; -using CPDLCServer.Messages; -using CPDLCServer.Model; -using CPDLCServer.Persistence; -using CPDLCServer.Services; -using MediatR; - - -namespace CPDLCServer.Handlers; - -public class SendUplinkCommandHandler( - IAircraftRepository aircraftRepository, - IClientManager clientManager, - IMessageIdProvider messageIdProvider, - IDialogueRepository dialogueRepository, - IMediator mediator, - IClock clock, - ILogger logger) - : IRequestHandler -{ - public async Task Handle(SendUplinkCommand request, CancellationToken cancellationToken) - { - logger.Information("Sending uplink message to {Callsign} (ReplyTo: {ReplyToDownlinkId}, Content: {Content})", - request.Recipient, request.ReplyToDownlinkId, request.Content); - - var allAircraft = await aircraftRepository.All(cancellationToken); - var aircraftConnection = - allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.CurrentDataAuthority) // Prefer the CDA connection - ?? allAircraft.FirstOrDefault(a => a.Callsign == request.Recipient && a.DataAuthorityState == DataAuthorityState.NextDataAuthority); // Defer to NDA connection if there's no CDA connection - if (aircraftConnection is null) - throw new Exception($"{request.Recipient} is not connected"); - - var client = await clientManager.GetAcarsClient(aircraftConnection.AcarsClientId, cancellationToken); - - var messageId = await messageIdProvider.GetNextMessageId( - aircraftConnection.AcarsClientId, - request.Recipient, - cancellationToken); - - var dialogue = new Dialogue(request.Recipient); - var uplinkMessage = dialogue.AddUplink( - messageId, - request.ReplyToDownlinkId, - request.Recipient, - request.Sender, - request.ResponseType, - AlertType.None, - request.Content, - clock.UtcNow()); - - await dialogueRepository.Add(dialogue, cancellationToken); - logger.Information("Dialogue {DialogueId} created for uplink message {MessageId} to {Callsign}", - dialogue.Id, messageId, request.Recipient); - - await mediator.Publish(new DialogueChangedNotification(dialogue), cancellationToken); - - await client.Send(uplinkMessage, cancellationToken); - logger.Information( - "Sent CPDLC message from {Sender} to {PilotCallsign}", - request.Sender, - uplinkMessage.Recipient); - - return new SendUplinkResult(uplinkMessage); - } -} diff --git a/source/CPDLCServer/Hubs/ControllerHub.cs b/source/CPDLCServer/Hubs/ControllerHub.cs index f24a549..08a1e81 100644 --- a/source/CPDLCServer/Hubs/ControllerHub.cs +++ b/source/CPDLCServer/Hubs/ControllerHub.cs @@ -71,7 +71,7 @@ await mediator.Publish( await base.OnConnectedAsync(); } - public async Task BeginDialogue( + public async Task BeginDialogue( string recipient, CpdlcUplinkResponseType responseType, string content) @@ -92,16 +92,14 @@ public async Task BeginDialogue( _ => throw new ArgumentOutOfRangeException(nameof(responseType), responseType, null) }; - var result = await mediator.Send(new BeginDialogueCommand( + await mediator.Send(new BeginDialogueCommand( controller.Callsign, recipient, modelResponseType, content)); - - return DialogueConverter.ToDto(result.UplinkMessage); } - public async Task ReplyToDownlink( + public async Task ReplyToDownlink( Guid dialogueId, int downlinkMessageId, CpdlcUplinkResponseType responseType, @@ -123,14 +121,12 @@ public async Task ReplyToDownlink( _ => throw new ArgumentOutOfRangeException(nameof(responseType), responseType, null) }; - var result = await mediator.Send(new ReplyToDownlinkCommand( + await mediator.Send(new ReplyToDownlinkCommand( controller.Callsign, dialogueId, downlinkMessageId, modelResponseType, content)); - - return DialogueConverter.ToDto(result.UplinkMessage); } public async Task GetConnectedAircraft() diff --git a/source/CPDLCServer/Messages/BeginDialogueCommand.cs b/source/CPDLCServer/Messages/BeginDialogueCommand.cs index 6b52922..dcf6264 100644 --- a/source/CPDLCServer/Messages/BeginDialogueCommand.cs +++ b/source/CPDLCServer/Messages/BeginDialogueCommand.cs @@ -8,4 +8,4 @@ public record BeginDialogueCommand( string Recipient, CpdlcUplinkResponseType ResponseType, string Content) - : IRequest; + : IRequest; diff --git a/source/CPDLCServer/Messages/LogonCommand.cs b/source/CPDLCServer/Messages/LogonCommand.cs index 9ce7227..d3ea11a 100644 --- a/source/CPDLCServer/Messages/LogonCommand.cs +++ b/source/CPDLCServer/Messages/LogonCommand.cs @@ -1,8 +1,14 @@ +using CPDLCServer.Model; using MediatR; namespace CPDLCServer.Messages; public record LogonCommand( int DownlinkId, + int? DownlinkMessageReference, string Callsign, - string AcarsClientId) : IRequest; \ No newline at end of file + CpdlcDownlinkResponseType DownlinkResponseType, + AlertType DownlinkAlertType, + string DownlinkContent, + DateTimeOffset DownlinkReceived, + string AcarsClientId) : IRequest; diff --git a/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs b/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs index 031439d..b7dd5bb 100644 --- a/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs +++ b/source/CPDLCServer/Messages/ReplyToDownlinkCommand.cs @@ -9,4 +9,4 @@ public record ReplyToDownlinkCommand( int DownlinkMessageId, CpdlcUplinkResponseType ResponseType, string Content) - : IRequest; + : IRequest; diff --git a/source/CPDLCServer/Messages/SendUplinkCommand.cs b/source/CPDLCServer/Messages/SendUplinkCommand.cs deleted file mode 100644 index 66868e4..0000000 --- a/source/CPDLCServer/Messages/SendUplinkCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CPDLCServer.Model; -using MediatR; - -namespace CPDLCServer.Messages; - -public record SendUplinkCommand( - string Sender, - string Recipient, - int? ReplyToDownlinkId, - CpdlcUplinkResponseType ResponseType, - string Content) - : IRequest; - -public record SendUplinkResult(UplinkMessage UplinkMessage); From b8e28c17a8529ea72208f8d948ff82702a0df8b3 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 23 May 2026 22:44:59 +1000 Subject: [PATCH 7/7] chore: Clean up --- .../Handlers/ReplyToDownlinkCommandHandlerTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs index ac2a2fc..02336d8 100644 --- a/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs +++ b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs @@ -8,10 +8,6 @@ namespace CPDLCServer.Tests.Handlers; -// Regression tests for issue #34 - "Replies from the CPDLC Editor are starting new messages" -// Root cause: the old implementation searched for dialogues by MessageId, which is only scoped -// per-session and collides across message types. The fix uses DialogueId for lookup so replies -// are always appended to the correct existing dialogue. public class ReplyToDownlinkCommandHandlerTests { [Fact] @@ -19,6 +15,7 @@ public async Task Handle_AppendsUplinkToExistingDialogue_NotCreatingNew() { // Regression for issue #34: controller replying to a pilot downlink must append // to the existing dialogue, not start a new one. + // Arrange var clientManager = new TestClientManager(); var messageIdProvider = new TestMessageIdProvider(); @@ -102,6 +99,7 @@ public async Task Handle_UsesDialogueId_WhenMultipleDialoguesHaveSameDownlinkMes // Regression for issue #34: the old code searched by MessageId, which is not globally // unique. Two dialogues for the same aircraft can legitimately have messages with the // same MessageId. The fix uses DialogueId for unambiguous lookup. + // Arrange var clientManager = new TestClientManager(); var messageIdProvider = new TestMessageIdProvider();