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/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..42e94dc 100644 --- a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs +++ b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs @@ -144,18 +144,31 @@ Func WithCancellationToken(Func SendUplink( - string recipient, - int? replyToDownlinkId, + public async Task BeginDialogue(string recipient, CpdlcUplinkResponseType responseType, string content, CancellationToken cancellationToken) { var connection = GetConnectedOrThrow(); - return await connection.InvokeAsync( - "SendUplink", + await connection.InvokeAsync( + "BeginDialogue", recipient, - replyToDownlinkId, + responseType, + content, + cancellationToken: cancellationToken); + } + + public async Task ReplyToDownlink(Guid dialogueId, + int downlinkMessageId, + CpdlcUplinkResponseType responseType, + string content, + CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + await connection.InvokeAsync( + "ReplyToDownlink", + dialogueId, + downlinkMessageId, responseType, content, cancellationToken: cancellationToken); 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/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.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.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..ef57462 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/BeginDialogueCommandHandlerTests.cs @@ -0,0 +1,156 @@ +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 + 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.IsType(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 + await handler.Handle(command, CancellationToken.None); + + // Assert + var dialogues = await dialogueRepository.All(CancellationToken.None); + var uplink = Assert.IsType(dialogues[0].Messages[0]); + Assert.Null(uplink.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 98e8ca8..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,19 +65,12 @@ 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 publisher = new TestPublisher(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -69,17 +82,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] @@ -108,19 +124,12 @@ 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 publisher = new TestPublisher(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -132,15 +141,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] @@ -169,19 +179,12 @@ 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 publisher = new TestPublisher(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -193,7 +196,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); @@ -250,19 +253,12 @@ 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 publisher = new TestPublisher(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); - 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); @@ -325,19 +321,12 @@ public async Task Handle_UpdatesLastSeen() clock.SetUtcNow(expectedLastSeen); var dialogueRepository = new TestDialogueRepository(); + var clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); - var publisher = new TestPublisher(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftManager, - mediator, - clock, - controllerManager, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftManager, clientManager, messageIdProvider, mediator, clock, controllerManager, dialogueRepository, hubContext); - var downlinkMessage = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -349,7 +338,7 @@ public async Task Handle_UpdatesLastSeen() var notification = new DownlinkReceivedNotification( "hoppies-ybbb", "YBBB", - downlinkMessage); + downlink); // Assert Assert.Equal(logonTime, aircraft.LastSeen); @@ -376,19 +365,12 @@ 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 publisher = new TestPublisher(); - var handler = new DownlinkReceivedNotificationHandler( - aircraftRepository, - mediator, - clock, - controllerRepository, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); - var downlink = new DownlinkMessage( + var downlink = new ReceivedDownlink( 1, null, "UAL123", @@ -402,15 +384,14 @@ public async Task Handle_CreatesNewDialogue_ForDownlinkWithNoReference() // Act await handler.Handle(notification, CancellationToken.None); - // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( - "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] @@ -428,9 +409,12 @@ 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 uplink = new UplinkMessage( + var existingDialogue = new Dialogue("UAL123"); + existingDialogue.AddUplink( 5, null, "UAL123", @@ -439,22 +423,11 @@ 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, - clock, - controllerRepository, - dialogueRepository, - hubContext, - publisher, - Logger.None); + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); - var downlink = new DownlinkMessage( + var downlink = new ReceivedDownlink( 10, 5, "UAL123", @@ -468,14 +441,269 @@ public async Task Handle_AppendsToExistingDialogue_ForDownlinkWithReference() // Act await handler.Handle(notification, CancellationToken.None); - // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( + // 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 clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + + 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( + 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>(); + 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"); + 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 = 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( + 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 clientManager = new TestClientManager(); + var messageIdProvider = new TestMessageIdProvider(); + + var handler = BuildHandler(aircraftRepository, clientManager, messageIdProvider, mediator, clock, controllerRepository, dialogueRepository, hubContext); + + 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); + + 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); - 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/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 new file mode 100644 index 0000000..02336d8 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/ReplyToDownlinkCommandHandlerTests.cs @@ -0,0 +1,254 @@ +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 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 + await handler.Handle( + new ReplyToDownlinkCommand("BN-TSN_FSS", dialogue.Id, 7, CpdlcUplinkResponseType.WilcoUnable, "DESCEND FL350"), + CancellationToken.None); + + // Assert + var uplink = dialogue.Messages.OfType().Single(); + Assert.Equal(7, uplink.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 deleted file mode 100644 index a28f28f..0000000 --- a/source/CPDLCServer.Tests/Handlers/SendUplinkCommandHandlerTests.cs +++ /dev/null @@ -1,341 +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.FindDialogueForMessage( - "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_AppendsToExistingDialogue_ForUplinkWithReference() - { - // Arrange - 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); - - // 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, - clientManager, - messageIdProvider, - dialogueRepository, - mediator, - clock, - Logger.None); - - var command = new SendUplinkCommand( - "BN-TSN_FSS", - "UAL123", - 5, - CpdlcUplinkResponseType.NoResponse, - "UNABLE"); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - var dialogue = await dialogueRepository.FindDialogueForMessage( - "UAL123", - 5, - CancellationToken.None); - - Assert.NotNull(dialogue); - Assert.Equal(2, dialogue.Messages.Count); - Assert.Contains(result.UplinkMessage, dialogue.Messages); - } - - [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.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/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.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); 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/BeginDialogueCommandHandler.cs b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs new file mode 100644 index 0000000..e172077 --- /dev/null +++ b/source/CPDLCServer/Handlers/BeginDialogueCommandHandler.cs @@ -0,0 +1,60 @@ +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 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 dialogue = new Dialogue(request.Recipient); + var uplinkMessage = dialogue.AddUplink( + messageId, + null, + request.Recipient, + request.Sender, + request.ResponseType, + AlertType.None, + request.Content, + clock.UtcNow()); + + 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); + } +} diff --git a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs index b6b5563..644ec5d 100644 --- a/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs +++ b/source/CPDLCServer/Handlers/DownlinkReceivedNotificationHandler.cs @@ -1,136 +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, - IPublisher publisher, - ILogger logger) - : INotificationHandler -{ - public async Task Handle(DownlinkReceivedNotification notification, CancellationToken cancellationToken) - { - logger.Information("Downlink message received from {Callsign}", notification.Downlink.Sender); - - // Intercept logon requests and automatically respond - Dialogue? dialogue; - if (ControlMessages.IsLogonRequest(notification.Downlink)) - { - // Create a dialogue for the logon request - dialogue = new Dialogue( - notification.Downlink.Sender, - notification.Downlink); - await dialogueRepository.Add(dialogue, cancellationToken); - - await mediator.Send( - new LogonCommand( - notification.Downlink.MessageId, - notification.Downlink.Sender, - notification.AcarsClientId), - cancellationToken); - return; - } - - var aircraftConnection = await aircraftRepository.Find( - new (notification.Downlink.Sender, notification.AcarsClientId), - cancellationToken); - - if (aircraftConnection is null) - { - logger.Information("{Callsign} is not known by this ATSU, sending error uplink", notification.Downlink.Sender); - - // Connection not known, reject. - await mediator.Send( - new SendUplinkCommand( - "SYSTEM", - notification.Downlink.Sender, - notification.Downlink.MessageId, - CpdlcUplinkResponseType.NoResponse, - "ERROR. CONNECTION NOT ESTABLISHED."), - cancellationToken); - return; - } - - // Intercept logoff messages - if (ControlMessages.IsLogoffNotice(notification.Downlink)) - { - await mediator.Send( - new TerminateConnectionRequest( - notification.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)) - { - aircraftConnection.PromoteToCurrentDataAuthority(); - logger.Information("{Callsign} promoted to CurrentDataAuthority", notification.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 - .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()); - - // 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); - } - 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); - } -} +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 new file mode 100644 index 0000000..8dfc823 --- /dev/null +++ b/source/CPDLCServer/Handlers/ReplyToDownlinkCommandHandler.cs @@ -0,0 +1,63 @@ +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 ReplyToDownlinkCommandHandler( + IAircraftRepository aircraftRepository, + IClientManager clientManager, + IMessageIdProvider messageIdProvider, + IDialogueRepository dialogueRepository, + IMediator mediator, + IClock clock, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(ReplyToDownlinkCommand request, CancellationToken cancellationToken) + { + 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 == callsign && a.DataAuthorityState == DataAuthorityState.CurrentDataAuthority) + ?? allAircraft.FirstOrDefault(a => a.Callsign == callsign && a.DataAuthorityState == DataAuthorityState.NextDataAuthority); + if (aircraftConnection is null) + throw new Exception($"{callsign} is not connected"); + + var client = await clientManager.GetAcarsClient(aircraftConnection.AcarsClientId, cancellationToken); + + var messageId = await messageIdProvider.GetNextMessageId( + aircraftConnection.AcarsClientId, + callsign, + cancellationToken); + + var uplinkMessage = dialogue.AddUplink( + messageId, + request.DownlinkMessageId, + callsign, + request.Sender, + request.ResponseType, + AlertType.None, + request.Content, + clock.UtcNow()); + + 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 {Callsign}", request.Sender, callsign); + } +} diff --git a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs b/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs deleted file mode 100644 index e1b4622..0000000 --- a/source/CPDLCServer/Handlers/SendUplinkCommandHandler.cs +++ /dev/null @@ -1,95 +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 uplinkMessage = new UplinkMessage( - messageId, - request.ReplyToDownlinkId, - request.Recipient, - request.Sender, - request.ResponseType, - AlertType.None, - 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); - } - - // Add or update the dialogue - var dialogue = request.ReplyToDownlinkId.HasValue - ? await dialogueRepository.FindDialogueForMessage( - 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); - logger.Information( - "Sent CPDLC message from {Sender} to {PilotCallsign}", - request.Sender, - uplinkMessage.Recipient); - - return new SendUplinkResult(uplinkMessage); - } -} 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..08a1e81 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,16 +92,41 @@ public async Task SendUplink( _ => throw new ArgumentOutOfRangeException(nameof(responseType), responseType, null) }; - var command = new SendUplinkCommand( + await mediator.Send(new BeginDialogueCommand( controller.Callsign, recipient, - replyToDownlinkId, modelResponseType, - content); + content)); + } + + 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 result = await mediator.Send(command); + 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) + }; - return DialogueConverter.ToDto(result.UplinkMessage); + await mediator.Send(new ReplyToDownlinkCommand( + controller.Callsign, + dialogueId, + downlinkMessageId, + modelResponseType, + content)); } public async Task GetConnectedAircraft() diff --git a/source/CPDLCServer/Messages/SendUplinkCommand.cs b/source/CPDLCServer/Messages/BeginDialogueCommand.cs similarity index 52% rename from source/CPDLCServer/Messages/SendUplinkCommand.cs rename to source/CPDLCServer/Messages/BeginDialogueCommand.cs index 66868e4..dcf6264 100644 --- a/source/CPDLCServer/Messages/SendUplinkCommand.cs +++ b/source/CPDLCServer/Messages/BeginDialogueCommand.cs @@ -3,12 +3,9 @@ namespace CPDLCServer.Messages; -public record SendUplinkCommand( +public record BeginDialogueCommand( string Sender, string Recipient, - int? ReplyToDownlinkId, CpdlcUplinkResponseType ResponseType, string Content) - : IRequest; - -public record SendUplinkResult(UplinkMessage UplinkMessage); + : IRequest; 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/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 new file mode 100644 index 0000000..b7dd5bb --- /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/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/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..b169347 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.IsClosed && + d.Messages.OfType().Any(m => m.MessageId == uplinkMessageId)); } }