From 9fc832f215362773f5e613d584e5d3630753186d Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Mon, 15 Jan 2024 12:22:53 +0100 Subject: [PATCH] New LastWillAndTestament Builder (#117) --- Documentation/docs/how-to/set-lwt.md | 37 ++- Source/HiveMQtt/Client/HiveMQClient.cs | 3 - .../Client/HiveMQClientOptionsBuilder.cs | 2 +- .../HiveMQtt/Client/LastWillAndTestament.cs | 108 +++++- .../Client/LastWillAndTestamentBuilder.cs | 309 ++++++++++++++++++ Source/HiveMQtt/Client/internal/Validator.cs | 123 +++++++ .../LastWillAndTestamentBuilderTest.cs | 105 ++++++ 7 files changed, 677 insertions(+), 10 deletions(-) create mode 100644 Source/HiveMQtt/Client/LastWillAndTestamentBuilder.cs create mode 100644 Source/HiveMQtt/Client/internal/Validator.cs create mode 100644 Tests/HiveMQtt.Test/HiveMQClient/LastWillAndTestamentBuilderTest.cs diff --git a/Documentation/docs/how-to/set-lwt.md b/Documentation/docs/how-to/set-lwt.md index bf51fa9d..fcfd7d68 100644 --- a/Documentation/docs/how-to/set-lwt.md +++ b/Documentation/docs/how-to/set-lwt.md @@ -4,6 +4,11 @@ The Last Will and Testament support of MQTT can be used to notify subscribers th For a more in-depth explanation, see [What is MQTT Last Will and Testament (LWT)? – MQTT Essentials: Part 9](https://www.hivemq.com/blog/mqtt-essentials-part-9-last-will-and-testament/). + +# LastWillAndTestament + +This example instantiates the `LastWillAndTestament` in the `HiveMQClientOption` class. This is then sent to the broker in the `connect` operation. + ```csharp // Specify the Last Will and Testament specifics in HiveMQClientOptions var options = new HiveMQClientOptions @@ -28,6 +33,36 @@ connectResult = await client.ConnectAsync().ConfigureAwait(false); // unexpectedly disconnected or alternatively, if your client disconnects with `DisconnectWithWillMessage` var disconnectOptions = new DisconnectOptions { ReasonCode = DisconnectReasonCode.DisconnectWithWillMessage }; var disconnectResult = await client.DisconnectAsync(disconnectOptions).ConfigureAwait(false); -`````` +``` Because the client above disconnected with `DisconnectReasonCode.DisconnectWithWillMessage`, subscribers to the `last/will` topic will receive the Last Will and Testament message as specified above. + +# LastWillAndTestament Builder Class + +As an ease-of-use alternative, the HiveMQtt client offers a `LastWillAndTestamentBuilder` class to more easily define a last will and testament class. + +```csharp + +var lwt = new LastWillAndTestamentBuilder() + .WithTopic("last/will") + .WithPayload("last will message") + .WithQualityOfServiceLevel(QualityOfService.AtLeastOnceDelivery) + .WithContentType("application/text") + .WithResponseTopic("response/topic") + .WithCorrelationData(new byte[] { 1, 2, 3, 4, 5 }) + .WithPayloadFormatIndicator(MQTT5PayloadFormatIndicator.UTF8Encoded) + .WithMessageExpiryInterval(100) + .WithUserProperty("userPropertyKey", "userPropertyValue") + .WithWillDelayInterval(1) + .Build(); + + // Setup & Connect the client with LWT + var options = new HiveMQClientOptions + { + LastWillAndTestament = lwt, + }; + + var client = new HiveMQClient(options); + connectResult = await client.ConnectAsync().ConfigureAwait(false); +``` + diff --git a/Source/HiveMQtt/Client/HiveMQClient.cs b/Source/HiveMQtt/Client/HiveMQClient.cs index c00615a8..0d737189 100644 --- a/Source/HiveMQtt/Client/HiveMQClient.cs +++ b/Source/HiveMQtt/Client/HiveMQClient.cs @@ -57,9 +57,6 @@ public HiveMQClient(HiveMQClientOptions? options = null) /// public List Subscriptions { get; } = new(); - /// - internal MQTT5Properties? ConnectionProperties { get; } - /// public bool IsConnected() => this.connectState == ConnectState.Connected; diff --git a/Source/HiveMQtt/Client/HiveMQClientOptionsBuilder.cs b/Source/HiveMQtt/Client/HiveMQClientOptionsBuilder.cs index f0064ce1..c9a83838 100644 --- a/Source/HiveMQtt/Client/HiveMQClientOptionsBuilder.cs +++ b/Source/HiveMQtt/Client/HiveMQClientOptionsBuilder.cs @@ -58,8 +58,8 @@ namespace HiveMQtt.Client; /// public class HiveMQClientOptionsBuilder { - private readonly HiveMQClientOptions options = new HiveMQClientOptions(); private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + private readonly HiveMQClientOptions options = new HiveMQClientOptions(); /// /// Sets the address of the broker to connect to. diff --git a/Source/HiveMQtt/Client/LastWillAndTestament.cs b/Source/HiveMQtt/Client/LastWillAndTestament.cs index 3e806f8d..ad9d06ab 100644 --- a/Source/HiveMQtt/Client/LastWillAndTestament.cs +++ b/Source/HiveMQtt/Client/LastWillAndTestament.cs @@ -16,6 +16,8 @@ namespace HiveMQtt.Client; using System.Text; +using HiveMQtt.Client.Exceptions; +using HiveMQtt.Client.Internal; using HiveMQtt.MQTT5.Types; /// @@ -23,16 +25,88 @@ namespace HiveMQtt.Client; /// public class LastWillAndTestament { + /// + /// Initializes a new instance of the class. + /// + /// This constructor is obsolete. Use the constructor that uses QualityOfService with a default value instead. + /// + /// + /// The topic of the Last Will and Testament. + /// The Quality of Service level for the Last Will and Testament. + /// The UTF-8 encoded payload of the Last Will and Testament. + /// A value indicating whether the Last Will and Testament should be retained by the MQTT broker when published. + [Obsolete("Use the LastWillAndTestament constructor that uses QualityOfService with a default value instead.")] public LastWillAndTestament(string topic, QualityOfService? qos, string payload, bool retain = false) { this.Topic = topic; - this.QoS = qos; + + if (qos is null) + { + this.QoS = QualityOfService.AtMostOnceDelivery; + } + else + { + this.QoS = (QualityOfService)qos; + } + this.PayloadAsString = payload; this.Retain = retain; this.UserProperties = new Dictionary(); } + /// + /// Initializes a new instance of the class. + /// + /// This constructor is obsolete. Use the constructor that uses QualityOfService with a default value instead. + /// + /// + /// The topic of the Last Will and Testament. + /// The Quality of Service level for the Last Will and Testament. + /// The byte payload of the Last Will and Testament. + /// A value indicating whether the Last Will and Testament should be retained by the MQTT broker when published. + [Obsolete("Use the LastWillAndTestament constructor that uses QualityOfService with a default value instead.")] public LastWillAndTestament(string topic, QualityOfService? qos, byte[] payload, bool retain = false) + { + this.Topic = topic; + + if (qos is null) + { + this.QoS = QualityOfService.AtMostOnceDelivery; + } + else + { + this.QoS = (QualityOfService)qos; + } + + this.Payload = payload; + this.Retain = retain; + this.UserProperties = new Dictionary(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The topic of the Last Will and Testament. + /// The UTF-8 encoded payload of the Last Will and Testament. + /// The Quality of Service level for the Last Will and Testament. + /// A value indicating whether the Last Will and Testament should be retained by the MQTT broker when published. + public LastWillAndTestament(string topic, string payload, QualityOfService qos = QualityOfService.AtMostOnceDelivery, bool retain = false) + { + this.Topic = topic; + this.QoS = qos; + this.PayloadAsString = payload; + this.Retain = retain; + this.UserProperties = new Dictionary(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The topic of the Last Will and Testament. + /// The byte payload of the Last Will and Testament. + /// The Quality of Service level for the Last Will and Testament. + /// A value indicating whether the Last Will and Testament should be retained by the MQTT broker when published. + public LastWillAndTestament(string topic, byte[] payload, QualityOfService qos = QualityOfService.AtMostOnceDelivery, bool retain = false) { this.Topic = topic; this.QoS = qos; @@ -44,12 +118,12 @@ public LastWillAndTestament(string topic, QualityOfService? qos, byte[] payload, /// /// Gets or sets the topic of this Publish. /// - public string? Topic { get; set; } + public string Topic { get; set; } /// /// Gets or sets the Quality of Service level for this publish. /// - public QualityOfService? QoS { get; set; } + public QualityOfService QoS { get; set; } /// /// Gets or sets the UTF-8 encoded payload of this Publish. @@ -98,7 +172,7 @@ public string PayloadAsString /// See Will Delay Interval. /// /// - public Int64? WillDelayInterval { get; set; } + public long? WillDelayInterval { get; set; } /// /// Gets or sets a value indicating the format of the Payload. @@ -108,7 +182,7 @@ public string PayloadAsString /// /// Gets or sets a value indicating the lifetime of the message in seconds. /// - public Int64? MessageExpiryInterval { get; set; } + public long? MessageExpiryInterval { get; set; } /// /// Gets or sets a value indicating the content type of the Payload. @@ -129,4 +203,28 @@ public string PayloadAsString /// Gets or sets a Dictionary containing the User Properties to be sent with the Last Will and Testament message. /// public Dictionary UserProperties { get; set; } + + /// + /// Validates the LastWillAndTestament. + /// + /// A value indicating whether the LastWillAndTestament is valid. + /// Thrown if the LastWillAndTestament is not valid. + public bool Validate() + { + if (this.Topic is null) + { + throw new HiveMQttClientException("LastWillAndTestament requires a Topic: Topic must not be null"); + } + else + { + Validator.ValidateTopicName(this.Topic); + } + + if (this.Payload is null) + { + throw new HiveMQttClientException("LastWillAndTestament requires a Payload: Payload must not be null"); + } + + return true; + } } diff --git a/Source/HiveMQtt/Client/LastWillAndTestamentBuilder.cs b/Source/HiveMQtt/Client/LastWillAndTestamentBuilder.cs new file mode 100644 index 00000000..c537cffa --- /dev/null +++ b/Source/HiveMQtt/Client/LastWillAndTestamentBuilder.cs @@ -0,0 +1,309 @@ +/* + * Copyright 2024-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace HiveMQtt.Client; + +using HiveMQtt.MQTT5.Types; +using HiveMQtt.Client.Internal; +using HiveMQtt.Client.Exceptions; +using System.Text; + +public class LastWillAndTestamentBuilder +{ + private readonly Dictionary userProperties = new Dictionary(); + private string? topic; + private byte[]? payload; + private QualityOfService qos = QualityOfService.AtMostOnceDelivery; + private bool retain; + private long? willDelayInterval; + private byte? payloadFormatIndicator; + private long? messageExpiryInterval; + private string? contentType; + private string? responseTopic; + private byte[]? correlationData; + + /// + /// Sets the Topic for the Last Will and Testament message. + /// + /// The topic name. + /// The builder instance. + public LastWillAndTestamentBuilder WithTopic(string topic) + { + this.topic = topic; + return this; + } + + /// + /// Sets the Payload for the Last Will and Testament message. + /// + /// The payload to send in bytes. + /// The builder instance. + public LastWillAndTestamentBuilder WithPayload(byte[] payload) + { + this.payload = payload; + return this; + } + + /// + /// Sets the Payload for the Last Will and Testament message. + /// + /// The string payload to send. + /// The builder instance. + public LastWillAndTestamentBuilder WithPayload(string payload) + { + this.payload = Encoding.UTF8.GetBytes(payload); + return this; + } + + /// + /// Sets the Quality of Service Level for the Last Will and Testament message. + /// + /// The quality of service level. + /// The builder instance. + public LastWillAndTestamentBuilder WithQualityOfServiceLevel(QualityOfService qos) + { + this.qos = qos; + return this; + } + + /// + /// Sets the Retain flag for the Last Will and Testament message. + /// + /// The boolean value of the retain flag. + /// The builder instance. + public LastWillAndTestamentBuilder WithRetain(bool retain) + { + this.retain = retain; + return this; + } + + /// + /// Sets the Will Delay Interval. This value is the delay the broker + /// should wait before publishing the Last Will and Testament message. + /// + /// If the Will Delay Interval is absent, the default value is 0 and + /// there is no delay before the Will Message is published. + /// + /// + /// Odd Fact: the maximum value of this option equates to + /// a 136 year delay. (2^32 - 1) seconds = 136 years. + /// + /// + /// The delay value in seconds. + /// The builder instance. + public LastWillAndTestamentBuilder WithWillDelayInterval(long willDelayInterval) + { + this.willDelayInterval = willDelayInterval; + return this; + } + + /// + /// Sets the Payload Format Indicator. This value indicates the format + /// of the payload. The value is a single byte with the following + /// possible values: + /// + /// + /// 0 + /// UTF-8 Encoded Character Data + /// + /// + /// 1 + /// Binary Data + /// + /// + /// + /// If the Payload Format Indicator is absent, the default value is 0. + /// + /// + /// The PayloadFormatIndicator value. + /// The builder instance. + public LastWillAndTestamentBuilder WithPayloadFormatIndicator(int payloadFormatIndicator) + { + if (payloadFormatIndicator is < 0 or > 1) + { + throw new HiveMQttClientException("Payload Format Indicator must be 0 or 1"); + } + + this.payloadFormatIndicator = (byte)payloadFormatIndicator; + return this; + } + + /// + /// Sets the Payload Format Indicator. This value indicates the format + /// of the payload. The value is a MQTT5PayloadFormatIndicator enum. + /// + /// A value from the MQTT5PayloadFormatIndicator enum. + /// The builder instance. + public LastWillAndTestamentBuilder WithPayloadFormatIndicator(MQTT5PayloadFormatIndicator payloadFormatIndicator) + { + this.payloadFormatIndicator = (byte)payloadFormatIndicator; + return this; + } + + /// + /// Sets the Message Expiry Interval. This value is the time in seconds + /// that the broker should retain the Last Will and Testament message. + /// + /// If the Message Expiry Interval is absent, the default value is 0 and + /// the message is retained until the Session ends. + /// + /// + /// Odd Fact: the maximum value of this option equates to + /// a 136 year delay. (2^32 - 1) seconds = 136 years. + /// + /// + /// The delay value in seconds. + /// The builder instance. + public LastWillAndTestamentBuilder WithMessageExpiryInterval(long messageExpiryInterval) + { + this.messageExpiryInterval = messageExpiryInterval; + return this; + } + + /// + /// Sets the Content Type. This value is a UTF-8 encoded string that + /// indicates the content type of the payload. + /// + /// The value of the Content Type is defined by the sending and + /// receiving application. + /// + /// + /// The payload content type. + /// The builder instance. + public LastWillAndTestamentBuilder WithContentType(string contentType) + { + this.contentType = contentType; + return this; + } + + /// + /// Sets the Response Topic. This value is a UTF-8 encoded string that + /// indicates the topic the receiver should use to respond to the + /// Last Will and Testament message. + /// + /// The topic name for a response message. + /// The builder instance. + public LastWillAndTestamentBuilder WithResponseTopic(string responseTopic) + { + this.responseTopic = responseTopic; + return this; + } + + /// + /// Sets the Correlation Data. This value is a byte array that + /// indicates the correlation data for the Last Will and Testament message. + /// + /// The Correlation Data is used by the sender of the Request Message + /// to identify which request the Response Message is for when it is + /// received. + /// + /// + /// The correlation data in bytes. + /// The builder instance. + public LastWillAndTestamentBuilder WithCorrelationData(byte[] correlationData) + { + this.correlationData = correlationData; + return this; + } + + /// + /// Set a user property to be sent with the Last Will and Testament message. + /// + /// The key of the user property. + /// The value of the user property to send. + /// The builder instance. + public LastWillAndTestamentBuilder WithUserProperty(string key, string value) + { + this.userProperties.Add(key, value); + return this; + } + + /// + /// Set a collection of user properties to be sent with the Last Will and Testament message. + /// + /// A dictionary of user properties to send. + /// The builder instance. + public LastWillAndTestamentBuilder WithUserProperties(Dictionary properties) + { + foreach (var property in properties) + { + this.userProperties.Add(property.Key, property.Value); + } + + return this; + } + + /// + /// Builds the Last Will and Testament message. + /// + /// The Last Will and Testament message. + /// Thrown if the Last Will and Testament message is invalid in any way. + public LastWillAndTestament Build() + { + if (this.topic is null) + { + throw new HiveMQttClientException("LastWillAndTestament requires a Topic: Topic must not be null"); + } + else + { + Validator.ValidateTopicName(this.topic); + } + + if (this.payload is null) + { + throw new HiveMQttClientException("LastWillAndTestament requires a Payload: Payload must not be null"); + } + + var lastWillAndTestament = new LastWillAndTestament(this.topic, this.payload, this.qos, this.retain); + + if (this.willDelayInterval.HasValue) + { + lastWillAndTestament.WillDelayInterval = this.willDelayInterval.Value; + } + + if (this.payloadFormatIndicator.HasValue) + { + lastWillAndTestament.PayloadFormatIndicator = this.payloadFormatIndicator.Value; + } + + if (this.messageExpiryInterval.HasValue) + { + lastWillAndTestament.MessageExpiryInterval = this.messageExpiryInterval.Value; + } + + if (this.contentType is not null) + { + lastWillAndTestament.ContentType = this.contentType; + } + + if (this.responseTopic is not null) + { + lastWillAndTestament.ResponseTopic = this.responseTopic; + } + + if (this.correlationData is not null) + { + lastWillAndTestament.CorrelationData = this.correlationData; + } + + if (this.userProperties.Count > 0) + { + lastWillAndTestament.UserProperties = this.userProperties; + } + + lastWillAndTestament.Validate(); + return lastWillAndTestament; + } +} diff --git a/Source/HiveMQtt/Client/internal/Validator.cs b/Source/HiveMQtt/Client/internal/Validator.cs new file mode 100644 index 00000000..468ebde6 --- /dev/null +++ b/Source/HiveMQtt/Client/internal/Validator.cs @@ -0,0 +1,123 @@ +/* + * Copyright 2024-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace HiveMQtt.Client.Internal; + +using System.Text.RegularExpressions; + +using HiveMQtt.Client.Exceptions; + +public class Validator +{ + /// + /// Validates the client identifier according to the MQTT v5.0 specification. + /// + /// The client identifier to validate. + public static void ValidateClientId(string clientId) + { + if (clientId is null) + { + throw new ArgumentNullException(nameof(clientId)); + } + + if (clientId.Length > 65535) + { + throw new HiveMQttClientException("Client identifier must not be longer than 65535 characters."); + } + + if (clientId.Length == 0) + { + throw new HiveMQttClientException("Client identifier must not be empty."); + } + + // Regular expression to match any character that is NOT in the specified set + var regex = new Regex("[^0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]"); + + // Check if the input string contains any character that does not match the pattern + if (regex.IsMatch(clientId)) + { + throw new HiveMQttClientException("MQTT Client IDs can only contain: 0-9, a-z, A-Z"); + } + } + + /// + /// Validates a topic name string according to the MQTT v5.0 specification. + /// + /// The topic name string to validate. + /// Thrown when the topic name is null. + /// Thrown when the topic name is longer than 65535 characters or empty. + /// Thrown when the topic name contains any wildcard characters. + /// Thrown when the topic name contains any null characters. + public static void ValidateTopicName(string topic) + { + if (topic is null) + { + throw new ArgumentNullException(nameof(topic)); + } + + if (topic.Length > 65535) + { + throw new HiveMQttClientException("A topic string must not be longer than 65535 characters."); + } + + if (topic.Length == 0) + { + throw new HiveMQttClientException("A topic string must not be empty."); + } + + // Topic names cannot contain wildcards (only TopicFilters can) + if (topic.Contains('+') || topic.Contains('#')) + { + throw new HiveMQttClientException("A topic name must not contain any wildcard characters."); + } + + if (topic.Contains('\0')) + { + throw new HiveMQttClientException("A topic name cannot contain any null characters."); + } + + } + + /// + /// Validates a topic filter string according to the MQTT v5.0 specification. + /// + /// The topic filter string to validate. + /// Thrown when the topic filter is null. + /// Thrown when the topic filter is longer than 65535 characters or empty. + /// Thrown when the topic filter contains any null characters. + public static void ValidateTopicFilter(string topic) + { + if (topic is null) + { + throw new ArgumentNullException(nameof(topic)); + } + + if (topic.Length > 65535) + { + throw new HiveMQttClientException("A topic string must not be longer than 65535 characters."); + } + + if (topic.Length == 0) + { + throw new HiveMQttClientException("A topic string must not be empty."); + } + + if (topic.Contains('\0')) + { + throw new HiveMQttClientException("A topic name cannot contain any null characters."); + } + } + +} diff --git a/Tests/HiveMQtt.Test/HiveMQClient/LastWillAndTestamentBuilderTest.cs b/Tests/HiveMQtt.Test/HiveMQClient/LastWillAndTestamentBuilderTest.cs new file mode 100644 index 00000000..da7cdfe6 --- /dev/null +++ b/Tests/HiveMQtt.Test/HiveMQClient/LastWillAndTestamentBuilderTest.cs @@ -0,0 +1,105 @@ +namespace HiveMQtt.Test.HiveMQClient; + +using System.Threading.Tasks; +using HiveMQtt.Client; +using HiveMQtt.Client.Options; +using HiveMQtt.MQTT5.ReasonCodes; +using HiveMQtt.MQTT5.Types; +using Xunit; + +public class LastWillAndTestamentBuilderTest +{ + [Fact] + public async Task Basic_Last_Will_Async() + { + + var lwt = new LastWillAndTestamentBuilder() + .WithTopic("last/will") + .WithPayload("last will message") + .WithQualityOfServiceLevel(QualityOfService.AtLeastOnceDelivery) + .Build(); + + var options = new HiveMQClientOptions + { + LastWillAndTestament = lwt, + }; + + var client = new HiveMQClient(options); + + var connectResult = await client.ConnectAsync().ConfigureAwait(false); + Assert.True(connectResult.ReasonCode == ConnAckReasonCode.Success); + Assert.True(client.IsConnected()); + } + + [Fact] + public async Task Last_Will_With_Properties_Async() + { + // Setup & Connect a client to listen for LWT + var listenerClient = new HiveMQClient(); + var connectResult = await listenerClient.ConnectAsync().ConfigureAwait(false); + Assert.True(connectResult.ReasonCode == ConnAckReasonCode.Success); + Assert.True(listenerClient.IsConnected()); + + var messagesReceived = 0; + var taskLWTReceived = new TaskCompletionSource(); + + // Set the event handler for the message received event + listenerClient.OnMessageReceived += (sender, args) => + { + messagesReceived++; + Assert.Equal(QualityOfService.AtLeastOnceDelivery, args.PublishMessage.QoS); + Assert.Equal("last/will", args.PublishMessage.Topic); + Assert.Equal("last will message", args.PublishMessage.PayloadAsString); + Assert.Equal("application/text", args.PublishMessage.ContentType); + Assert.Equal("response/topic", args.PublishMessage.ResponseTopic); + Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, args.PublishMessage.CorrelationData); + Assert.Equal(MQTT5PayloadFormatIndicator.UTF8Encoded, args.PublishMessage.PayloadFormatIndicator); + Assert.Equal(100, args.PublishMessage.MessageExpiryInterval); + Assert.Single(args.PublishMessage.UserProperties); + Assert.True(args.PublishMessage.UserProperties.ContainsKey("userPropertyKey")); + Assert.Equal("userPropertyValue", args.PublishMessage.UserProperties["userPropertyKey"]); + + Assert.NotNull(sender); + + // Notify that we've received the LWT message + taskLWTReceived.SetResult(true); + }; + + var result = await listenerClient.SubscribeAsync("last/will", QualityOfService.AtLeastOnceDelivery).ConfigureAwait(false); + Assert.Single(result.Subscriptions); + Assert.Equal(SubAckReasonCode.GrantedQoS1, result.Subscriptions[0].SubscribeReasonCode); + Assert.Equal("last/will", result.Subscriptions[0].TopicFilter.Topic); + + var lwt = new LastWillAndTestamentBuilder() + .WithTopic("last/will") + .WithPayload("last will message") + .WithQualityOfServiceLevel(QualityOfService.AtLeastOnceDelivery) + .WithContentType("application/text") + .WithResponseTopic("response/topic") + .WithCorrelationData(new byte[] { 1, 2, 3, 4, 5 }) + .WithPayloadFormatIndicator(MQTT5PayloadFormatIndicator.UTF8Encoded) + .WithMessageExpiryInterval(100) + .WithUserProperty("userPropertyKey", "userPropertyValue") + .WithWillDelayInterval(1) + .Build(); + + // Setup & Connect the client with LWT + var options = new HiveMQClientOptions + { + LastWillAndTestament = lwt, + }; + + var client = new HiveMQClient(options); + connectResult = await client.ConnectAsync().ConfigureAwait(false); + Assert.True(connectResult.ReasonCode == ConnAckReasonCode.Success); + Assert.True(client.IsConnected()); + + // Call DisconnectWithWillMessage. listenerClient should receive the LWT message + var disconnectOptions = new DisconnectOptions { ReasonCode = DisconnectReasonCode.DisconnectWithWillMessage }; + var disconnectResult = await client.DisconnectAsync(disconnectOptions).ConfigureAwait(false); + + // Wait until the LWT message is received + var taskResult = await taskLWTReceived.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.True(taskResult); + } +}