diff --git a/.github/workflows/e2e-ci.yaml b/.github/workflows/e2e-ci.yaml index 8d24967d81..6cbbc0fae3 100644 --- a/.github/workflows/e2e-ci.yaml +++ b/.github/workflows/e2e-ci.yaml @@ -14,7 +14,7 @@ on: # rebuild any PRs and main branch changes description: 'Include Load Tests in current run' default: 'false' TestsToRun: - default: '[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest]' + default: '[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest,CloudDeploymentTest]' description: 'tests to run' TxPower: description: 'TXPower value to use in E2E tests' @@ -58,10 +58,10 @@ jobs: echo "::set-output name=E2ETestsToRun::${{ github.event.inputs.TestsToRun }}" elif [ ${{ github.event_name }} == 'pull_request' ]; then echo "Set up for pull request" - echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest]" + echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest,CloudDeploymentTest]" else echo "Set up for cron trigger" - echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest]" + echo "::set-output name=E2ETestsToRun::[SensorDecodingTest,OTAAJoinTest,ABPTest,OTAATest,MacTest,ClassCTest,C2DMessageTest,MultiGatewayTest,MultiConcentratorTest,CupsTest,LnsDiscoveryTest,CloudDeploymentTest]" fi - id: check-if-run @@ -170,7 +170,8 @@ jobs: with: fetch-depth: '2' - - run: | + - id: image_tag_definition + run: | if [ ${{ github.ref }} = "refs/heads/dev" ]; then echo "dev" IMAGE_TAG="$DEV_IMAGE_TAG" @@ -189,6 +190,7 @@ jobs: echo "Using image tag $IMAGE_TAG" echo "::set-env name=NET_SRV_VERSION::$IMAGE_TAG" echo "::set-env name=LBS_VERSION::$IMAGE_TAG" + echo "::set-output name=imagetag::$IMAGE_TAG" env: ACTIONS_ALLOW_UNSECURE_COMMANDS: true @@ -216,6 +218,9 @@ jobs: env: CONTAINER_REGISTRY_ADDRESS: ${{ env.CONTAINER_REGISTRY_ADDRESS }} + outputs: + imagetag: ${{ steps.image_tag_definition.outputs.imagetag }} + # Generate root and server certificates and copy required files to RPi certificates_job : timeout-minutes: 5 @@ -287,6 +292,36 @@ jobs: clientfwdigest: ${{ steps.generate_step.outputs.clientfwdigest }} clientfwversion: ${{ steps.generate_step.outputs.clientfwversion }} + # Deploy Cloud based LoRaWAN Network Server + deploy_cloud_lns: + needs: + - env_var + - build_push_docker_images + - certificates_job + runs-on: ubuntu-latest + if: needs.env_var.outputs.RunE2ETestsOnly != 'true' && needs.env_var.outputs.StopFullCi != 'true' + name: Deploy Cloud based LNS + steps: + - name: "Deploy container instance" + id: "deploycloudlns" + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_SP_CLIENTID }} -p ${{ secrets.AZURE_SP_SECRET }} --tenant ${{ secrets.AZURE_TENANTID }} + az container create --resource-group ${{ secrets.AZURE_RG }} --ip-address Private --location westeurope --name cloudlns \ + --environment-variables LOG_TO_TCP_ADDRESS=${{ needs.certificates_job.outputs.itestupip }} LOG_TO_TCP_PORT=6100 LOG_TO_TCP=true LOG_LEVEL=1 IOTHUBHOSTNAME=${{secrets.IOTHUB_HOSTNAME}} ENABLE_GATEWAY=false CLOUD_DEPLOYMENT=true \ + --image ${{ env.CONTAINER_REGISTRY_ADDRESS }}/lorawannetworksrvmodule:${{needs.build_push_docker_images.outputs.imagetag}}-amd64 \ + --ports 5000 \ + --protocol TCP \ + --registry-username ${{ env.CONTAINER_REGISTRY_USERNAME }} \ + --registry-password ${{ env.CONTAINER_REGISTRY_PASSWORD }} \ + --restart-policy Never \ + --secure-environment-variables FACADE_AUTH_CODE=${{ secrets.FUNCTION_FACADE_AUTH_CODE }} FACADE_SERVER_URL=${{ secrets.FUNCTION_FACADE_SERVER_URL }} REDIS_CONNECTION_STRING=${{ secrets.REDIS_HOSTNAME }}.redis.cache.windows.net:6380,password=${{ secrets.REDIS_PASSWORD }},ssl=True,abortConnect=False \ + --subnet ${{ secrets.AZURE_SUBNET_NAME }} \ + --vnet ${{ secrets.AZURE_VNET_NAME }} --output none + echo "::set-output name=cloudlnsprivateip::$(az container show --name cloudlns --resource-group ${{ secrets.AZURE_RG }} --query ipAddress.ip -o tsv)" + outputs: + cloudlnsprivateip: ${{ steps.deploycloudlns.outputs.cloudlnsprivateip }} + # Deploy IoT Edge solution to ARM gateway deploy_arm_gw_iot_edge: timeout-minutes: 20 @@ -433,7 +468,9 @@ jobs: INTEGRATIONTEST_LoadTestLnsEndpoints: ${{ secrets.LOAD_TEST_LNS_ENDPOINTS }} INTEGRATIONTEST_NumberOfLoadTestDevices: 10 INTEGRATIONTEST_NumberOfLoadTestConcentrators: 4 - + INTEGRATIONTEST_FunctionAppCode: ${{ secrets.FUNCTION_FACADE_AUTH_CODE }} + INTEGRATIONTEST_FunctionAppBaseUrl: ${{ secrets.FUNCTION_FACADE_SERVER_URL }} + INTEGRATIONTEST_TcpLogPort: 6000 steps: - uses: actions/checkout@v3 name: Checkout current branch @@ -460,7 +497,7 @@ jobs: shell: bash run: | dotnet test --logger trx --no-build --configuration ${{ env.BUILD_CONFIGURATION }} \ - -r ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ \ + -r ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ --filter "SimulatedLoadTests" \ ${{ env.TESTS_FOLDER }}/Simulation/LoRaWan.Tests.Simulation.csproj # Upload test results as artifact @@ -470,6 +507,77 @@ jobs: name: load-test-results path: ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ + cloud_test_job: + timeout-minutes: 150 + name: Run cloud only deployment Tests + environment: + name: CI_AZURE_ENVIRONMENT + url: ${{ needs.env_var.outputs.CheckSuiteUrl }} + if: always() && needs.deploy_cloud_lns.result == 'success' && needs.deploy_facade_function.result == 'success' && needs.env_var.outputs.StopFullCi != 'true' && contains(needs.env_var.outputs.E2ETestsToRun, 'CloudDeploymentTest') && !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'CloudDeploymentTest')) + needs: + - deploy_facade_function + - deploy_cloud_lns + - env_var + - certificates_job + runs-on: [ self-hosted, x64 ] + env: + INTEGRATIONTEST_IoTHubEventHubConnectionString: ${{ secrets.IOTHUB_EVENT_HUB_CONNECTION_STRING }} + INTEGRATIONTEST_IoTHubEventHubConsumerGroup: ${{ secrets.IOTHUB_CI_CONSUMER_GROUP }} + INTEGRATIONTEST_IoTHubConnectionString: ${{ secrets.IOTHUB_OWNER_CONNECTION_STRING }} + INTEGRATIONTEST_LeafDeviceGatewayID: itestarm1 + INTEGRATIONTEST_DevicePrefix: '12' + INTEGRATIONTEST_RunningInCI: true + INTEGRATIONTEST_LoadTestLnsEndpoints: "{ \\\"1\\\": \\\"ws://${{ needs.deploy_cloud_lns.outputs.cloudlnsprivateip }}:5000\\\" }" + INTEGRATIONTEST_NumberOfLoadTestDevices: 1 + INTEGRATIONTEST_NumberOfLoadTestConcentrators: 2 + INTEGRATIONTEST_FunctionAppCode: ${{ secrets.FUNCTION_FACADE_AUTH_CODE }} + INTEGRATIONTEST_FunctionAppBaseUrl: ${{ secrets.FUNCTION_FACADE_SERVER_URL }} + INTEGRATIONTEST_TcpLogPort: 6100 + + steps: + - uses: actions/checkout@v2 + name: Checkout current branch + + - name: Setup .NET + uses: actions/setup-dotnet@v1.8.2 + with: + dotnet-version: '6.0.x' # SDK Version to use. + + - name: .NET SDK Information + run: + dotnet --info + + - name: Configuration for simulated cloud tests + uses: cschleiden/replace-tokens@v1 + with: + files: '${{ env.TESTS_FOLDER }}/Simulation/appsettings.json' + + - name: Build simulated cloud tests + run: | + dotnet build --configuration ${{ env.BUILD_CONFIGURATION }} ${{ env.TESTS_FOLDER }}/Simulation/LoRaWan.Tests.Simulation.csproj + + - name: Runs simulated cloud tests + shell: bash + run: | + dotnet test --logger trx --no-build --configuration ${{ env.BUILD_CONFIGURATION }} \ + -r ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ --filter "SimulatedCloudTests" \ + ${{ env.TESTS_FOLDER }}/Simulation/LoRaWan.Tests.Simulation.csproj + + - name: Add CloudDeploymentTest Test Label + uses: buildsville/add-remove-label@v1 + if: github.event_name == 'pull_request' && success() + with: + token: ${{ github.token }} + label: 'CloudDeploymentTest' + type: add + + # Upload simulated cloud results as artifact + - uses: actions/upload-artifact@v1 + if: always() + with: + name: simulated-cloud-test-results + path: ${{ env.TESTS_RESULTS_FOLDER }}/LoadTest/ + # Runs E2E tests in dedicated agent, while having modules deployed into PI (arm32v7) e2e_tests_job: timeout-minutes: 150 @@ -647,3 +755,18 @@ jobs: AZURE_SP_CLIENTID: ${{ secrets.AZURE_SP_CLIENTID }} AZURE_SP_SECRET: ${{ secrets.AZURE_SP_SECRET }} AZURE_TENANTID: ${{ secrets.AZURE_TENANTID }} + + delete_cloud_lns: + name: Delete cloud LNS + if: always() + runs-on: ubuntu-latest + needs: + - e2e_tests_job + steps: + - uses: actions/checkout@v2 + - name: "Delete azure container instance for cloudlns" + shell: bash + run: | + az login --service-principal -u ${{ secrets.AZURE_SP_CLIENTID }} -p ${{ secrets.AZURE_SP_SECRET }} --tenant ${{ secrets.AZURE_TENANTID }} + az container logs -g ${{ secrets.AZURE_RG }} --name cloudlns + az container delete --yes --resource-group ${{ secrets.AZURE_RG }} --name cloudlns --output none diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index 4de6dfcd02..124162d932 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -11,4 +11,5 @@ [assembly: InternalsVisibleTo("LoRaWan.Tests.Integration")] [assembly: InternalsVisibleTo("LoRaWan.Tests.E2E")] [assembly: InternalsVisibleTo("LoRaWan.Tests.Common")] +[assembly: InternalsVisibleTo("LoRaWan.Tests.Simulation")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs b/LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs new file mode 100644 index 0000000000..a871d3f4fb --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/ RedisChannelPublisher.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using StackExchange.Redis; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using LoRaTools; + using System.Text.Json; + + public class RedisChannelPublisher : IChannelPublisher + { + private readonly ConnectionMultiplexer redis; + private readonly ILogger logger; + + public RedisChannelPublisher(ConnectionMultiplexer redis, ILogger logger) + { + this.redis = redis; + this.logger = logger; + } + + public async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) + { + this.logger.LogDebug("Publishing message to channel '{Channel}'.", channel); + _ = await this.redis.GetSubscriber().PublishAsync(channel, JsonSerializer.Serialize(lnsRemoteCall)); + } + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs b/LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs new file mode 100644 index 0000000000..8ef1790785 --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/ClearLnsCache.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoraKeysManagerFacade +{ + using System; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.Devices; + using Microsoft.Azure.WebJobs; + using Microsoft.Azure.WebJobs.Extensions.Http; + using Microsoft.Extensions.Logging; + + public sealed class ClearLnsCache + { + private readonly IEdgeDeviceGetter edgeDeviceGetter; + private readonly IServiceClient serviceClient; + private readonly IChannelPublisher channelPublisher; + private readonly ILogger logger; + + public ClearLnsCache(IEdgeDeviceGetter edgeDeviceGetter, + IServiceClient serviceClient, + IChannelPublisher channelPublisher, + ILogger logger) + { + this.edgeDeviceGetter = edgeDeviceGetter; + this.serviceClient = serviceClient; + this.channelPublisher = channelPublisher; + this.logger = logger; + } + + [FunctionName(nameof(ClearNetworkServerCache))] + public async Task ClearNetworkServerCache([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, CancellationToken cancellationToken) + { + if (req is null) throw new ArgumentNullException(nameof(req)); + + try + { + VersionValidator.Validate(req); + } + catch (IncompatibleVersionException ex) + { + return new BadRequestObjectResult(ex.Message); + } + + await ClearLnsCacheInternalAsync(cancellationToken); + + return new AcceptedResult(); + } + + internal async Task ClearLnsCacheInternalAsync(CancellationToken cancellationToken) + { + this.logger.LogInformation("Clearing device cache for all edge and Pub/Sub channel based Network Servers."); + // Edge device discovery for invoking direct methods + var edgeDevices = await this.edgeDeviceGetter.ListEdgeDevicesAsync(cancellationToken); + if (this.logger.IsEnabled(LogLevel.Debug)) + { + this.logger.LogDebug("Invoking clear cache direct method for following devices: {deviceList}", string.Join(',', edgeDevices)); + } + var tasks = edgeDevices.Select(e => InvokeClearViaDirectMethodAsync(e, cancellationToken)).ToArray(); + // Publishing a single message for all cloud based LNSes + await PublishClearMessageAsync(); + await Task.WhenAll(tasks); + } + + internal async Task PublishClearMessageAsync() + { + await this.channelPublisher.PublishAsync(LoraKeysManagerFacadeConstants.ClearCacheMethodName, new LnsRemoteCall(RemoteCallKind.ClearCache, null)); + this.logger.LogInformation("Cache clear message published on Pub/Sub channel"); + } + + internal async Task InvokeClearViaDirectMethodAsync(string lnsId, CancellationToken cancellationToken) + { + //Reason why the yield is needed is to avoid any potential "synchronous" code that might fail the publishing of a message on the pub/sub channel + await Task.Yield(); + var res = await this.serviceClient.InvokeDeviceMethodAsync(lnsId, + LoraKeysManagerFacadeConstants.NetworkServerModuleId, + new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.ClearCacheMethodName), + cancellationToken); + if (HttpUtilities.IsSuccessStatusCode(res.Status)) + { + this.logger.LogInformation("Cache cleared for {gatewayID} via direct method", lnsId); + } + else + { + throw new InvalidOperationException($"Direct method call to {lnsId} failed with {res.Status}. Response: {res.GetPayloadAsJson()}"); + } + } + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs new file mode 100644 index 0000000000..862dcb4170 --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/EdgeDeviceGetter.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging; + + public class EdgeDeviceGetter : IEdgeDeviceGetter + { + private readonly IDeviceRegistryManager registryManager; + private readonly ILoRaDeviceCacheStore cacheStore; + private readonly ILogger logger; + private DateTimeOffset? lastUpdateTime; + + public EdgeDeviceGetter(IDeviceRegistryManager registryManager, + ILoRaDeviceCacheStore cacheStore, + ILogger logger) + { + this.registryManager = registryManager; + this.cacheStore = cacheStore; + this.logger = logger; + } + +#pragma warning disable IDE0060 // Remove unused parameter. Kept here for future improvements of RegistryManager + private async Task> GetEdgeDevicesAsync(CancellationToken cancellationToken) +#pragma warning restore IDE0060 // Remove unused parameter + { + this.logger.LogDebug("Getting Azure IoT Edge devices"); + var q = this.registryManager.CreateQuery($"SELECT * FROM devices.modules where moduleId = '{LoraKeysManagerFacadeConstants.NetworkServerModuleId}'"); + var twins = new List(); + do + { + twins.AddRange(await q.GetNextAsTwinAsync()); + } while (q.HasMoreResults); + return twins; + } + + public async Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken) + { + const string keyLock = $"{nameof(EdgeDeviceGetter)}-lock"; + const string owner = nameof(EdgeDeviceGetter); + var isEdgeDevice = false; + try + { + if (await this.cacheStore.LockTakeAsync(keyLock, owner, TimeSpan.FromSeconds(10))) + { + var findInCache = () => this.cacheStore.GetObject(RedisLnsDeviceCacheKey(lnsId)); + var firstSearch = findInCache(); + if (firstSearch is null) + { + await RefreshEdgeDevicesCacheAsync(cancellationToken); + isEdgeDevice = findInCache() is { IsEdge: true }; + if (!isEdgeDevice) + { + var marked = MarkDeviceAsNonEdge(lnsId); + if (!marked) + this.logger.LogError("Could not update Redis Edge Device cache status for device {}", lnsId); + } + } + else + { + return firstSearch.IsEdge; + } + } + else + { + throw new TimeoutException("Timed out while taking a lock on Redis Edge Device cache"); + } + } + finally + { + _ = this.cacheStore.LockRelease(keyLock, owner); + } + return isEdgeDevice; + } + + private static string RedisLnsDeviceCacheKey(string lnsId) => $"lnsInstance-{lnsId}"; + + private bool MarkDeviceAsNonEdge(string lnsId) + => this.cacheStore.ObjectSet(RedisLnsDeviceCacheKey(lnsId), + new DeviceKind(isEdge: false), + TimeSpan.FromDays(1), + onlyIfNotExists: true); + + private async Task RefreshEdgeDevicesCacheAsync(CancellationToken cancellationToken) + { + this.logger.LogDebug("Refreshing Azure IoT Edge devices cache"); + if (this.lastUpdateTime is null + || this.lastUpdateTime - DateTimeOffset.UtcNow >= TimeSpan.FromMinutes(1)) + { + var twins = await GetEdgeDevicesAsync(cancellationToken); + foreach (var t in twins) + { + _ = this.cacheStore.ObjectSet(RedisLnsDeviceCacheKey(t.DeviceId), + new DeviceKind(isEdge: true), + TimeSpan.FromDays(1), + onlyIfNotExists: true); + } + this.lastUpdateTime = DateTimeOffset.UtcNow; + } + } + + public async Task> ListEdgeDevicesAsync(CancellationToken cancellationToken) + { + var edgeDevices = await GetEdgeDevicesAsync(cancellationToken); + return edgeDevices.Select(e => e.DeviceId).ToList(); + } + } + + internal class DeviceKind + { + public bool IsEdge { get; private set; } + public DeviceKind(bool isEdge) + { + IsEdge = isEdge; + } + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs index c51b9cae98..d59579ba31 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FacadeStartup.cs @@ -59,7 +59,9 @@ public override void Configure(IFunctionsHostBuilder builder) sp.GetRequiredService(), sp.GetRequiredService>())) .AddSingleton() + .AddSingleton(sp => new RedisChannelPublisher(redis, sp.GetRequiredService>())) .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs index 3ebd8e9484..6b15339329 100644 --- a/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs +++ b/LoRaEngine/LoraKeysManagerFacade/FunctionsBundler/DeduplicationExecutionItem.cs @@ -4,7 +4,9 @@ namespace LoraKeysManagerFacade.FunctionBundler { using System; + using System.Threading; using System.Threading.Tasks; + using LoRaTools; using LoRaTools.CommonAPI; using LoRaWan; using Microsoft.ApplicationInsights; @@ -21,16 +23,23 @@ public class DeduplicationExecutionItem : IFunctionBundlerExecutionItem private readonly ILoRaDeviceCacheStore cacheStore; private readonly IServiceClient serviceClient; + private readonly IEdgeDeviceGetter edgeDeviceGetter; + private readonly IChannelPublisher channelPublisher; private readonly Microsoft.ApplicationInsights.Metric connectionOwnershipChangedMetric; + private static readonly TimeSpan DuplicateMessageTimeout = TimeSpan.FromSeconds(30); + public DeduplicationExecutionItem( ILoRaDeviceCacheStore cacheStore, IServiceClient serviceClient, + IEdgeDeviceGetter edgeDeviceGetter, + IChannelPublisher channelPublisher, TelemetryConfiguration telemetryConfiguration) { this.cacheStore = cacheStore; this.serviceClient = serviceClient; - + this.edgeDeviceGetter = edgeDeviceGetter; + this.channelPublisher = channelPublisher; var telemetryClient = new TelemetryClient(telemetryConfiguration); var metricIdentifier = new MetricIdentifier(LoraKeysManagerFacadeConstants.MetricNamespace, ConnectionOwnershipChangeMetricName); this.connectionOwnershipChangedMetric = telemetryClient.GetMetric(metricIdentifier); @@ -59,6 +68,7 @@ public Task OnAbortExecutionAsync(IPipelineExecutionContext context) internal async Task GetDuplicateMessageResultAsync(DevEui devEUI, string gatewayId, uint clientFCntUp, uint clientFCntDown, ILogger logger = null) { + using var cts = new CancellationTokenSource(DuplicateMessageTimeout); var isDuplicate = true; var processedDevice = gatewayId; @@ -105,16 +115,26 @@ internal async Task GetDuplicateMessageResultAsync(DevEui de }; var method = new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); - _ = method.SetPayloadJson(JsonConvert.SerializeObject(loraC2DMessage)); + var jsonContents = JsonConvert.SerializeObject(loraC2DMessage); + _ = method.SetPayloadJson(jsonContents); try { - var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); - logger?.LogDebug("Connection owner changed and direct method was called on previous gateway '{PreviousConnectionOwner}' to close connection; result is '{Status}'", previousGateway, res?.Status); + if (await this.edgeDeviceGetter.IsEdgeDeviceAsync(previousGateway, cts.Token)) + { + var res = await this.serviceClient.InvokeDeviceMethodAsync(previousGateway, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method, default); + logger?.LogDebug("Connection owner changed and direct method was called on previous gateway '{PreviousConnectionOwner}' to close connection; result is '{Status}'", previousGateway, res?.Status); - if (!HttpUtilities.IsSuccessStatusCode(res.Status)) + if (res is null || (res is { } && !HttpUtilities.IsSuccessStatusCode(res.Status))) + { + logger?.LogError("Failed to invoke direct method on LNS '{PreviousConnectionOwner}' to close the connection for device '{DevEUI}'; status '{Status}'", previousGateway, devEUI, res?.Status); + } + + } + else { - logger?.LogError("Failed to invoke direct method on LNS '{PreviousConnectionOwner}' to close the connection for device '{DevEUI}'; status '{Status}'", previousGateway, devEUI, res?.Status); + await this.channelPublisher.PublishAsync(previousGateway, new LnsRemoteCall(RemoteCallKind.CloseConnection, jsonContents)); + logger?.LogDebug("Connection owner changed and message was published to previous gateway '{PreviousConnectionOwner}' to close connection", previousGateway); } } catch (IotHubException ex) diff --git a/LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs b/LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs new file mode 100644 index 0000000000..4bd58a05bc --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/IChannelPublisher.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using System.Threading.Tasks; + using LoRaTools; + + /// + /// Interface for publisher interation. + /// + public interface IChannelPublisher + { + Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall); + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs b/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs new file mode 100644 index 0000000000..d96d87ba9b --- /dev/null +++ b/LoRaEngine/LoraKeysManagerFacade/IEdgeDeviceGetter.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoraKeysManagerFacade +{ + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + public interface IEdgeDeviceGetter + { + Task IsEdgeDeviceAsync(string lnsId, CancellationToken cancellationToken); + Task> ListEdgeDevicesAsync(CancellationToken cancellationToken); + } +} diff --git a/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs b/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs index 7899971ba9..eacbb6eff5 100644 --- a/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs +++ b/LoRaEngine/LoraKeysManagerFacade/IServiceClient.cs @@ -3,6 +3,7 @@ namespace LoraKeysManagerFacade { + using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Devices; @@ -11,7 +12,7 @@ namespace LoraKeysManagerFacade /// public interface IServiceClient { - Task InvokeDeviceMethodAsync(string deviceId, string moduleId, CloudToDeviceMethod cloudToDeviceMethod); + Task InvokeDeviceMethodAsync(string deviceId, string moduleId, CloudToDeviceMethod cloudToDeviceMethod, CancellationToken cancellationToken); Task SendAsync(string deviceId, Message message); } diff --git a/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs b/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs index 358e16bdfc..fa7565e387 100644 --- a/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs +++ b/LoRaEngine/LoraKeysManagerFacade/LoraKeysManagerFacadeConstants.cs @@ -11,6 +11,7 @@ internal static class LoraKeysManagerFacadeConstants internal const string TwinProperty_DevAddr = "DevAddr"; internal const string TwinProperty_NwkSKey = "NwkSKey"; internal const string NetworkServerModuleId = "LoRaWanNetworkSrvModule"; + internal const string ClearCacheMethodName = "clearcache"; internal const string CloudToDeviceMessageMethodName = "cloudtodevicemessage"; internal const string CloudToDeviceCloseConnection = "closeconnection"; public const string RoundTripDateTimeStringFormat = "o"; diff --git a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs index 2bad11c62a..54f904a9b9 100644 --- a/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs +++ b/LoRaEngine/LoraKeysManagerFacade/SendCloudToDeviceMessage/SendCloudToDeviceMessage.cs @@ -8,6 +8,7 @@ namespace LoraKeysManagerFacade using System.Linq; using System.Net; using System.Text; + using System.Threading; using System.Threading.Tasks; using LoRaTools; using LoRaTools.CommonAPI; @@ -33,20 +34,30 @@ public class SendCloudToDeviceMessage private readonly ILoRaDeviceCacheStore cacheStore; private readonly IDeviceRegistryManager registryManager; private readonly IServiceClient serviceClient; + private readonly IEdgeDeviceGetter edgeDeviceGetter; + private readonly IChannelPublisher channelPublisher; private readonly ILogger log; - public SendCloudToDeviceMessage(ILoRaDeviceCacheStore cacheStore, IDeviceRegistryManager registryManager, IServiceClient serviceClient, ILogger log) + public SendCloudToDeviceMessage(ILoRaDeviceCacheStore cacheStore, + IDeviceRegistryManager registryManager, + IServiceClient serviceClient, + IEdgeDeviceGetter edgeDeviceGetter, + IChannelPublisher channelPublisher, + ILogger log) { this.cacheStore = cacheStore; this.registryManager = registryManager; this.serviceClient = serviceClient; + this.edgeDeviceGetter = edgeDeviceGetter; + this.channelPublisher = channelPublisher; this.log = log; } [FunctionName("SendCloudToDeviceMessage")] public async Task Run( [HttpTrigger(AuthorizationLevel.Function, "post", Route = "cloudtodevicemessage/{devEUI}")] HttpRequest req, - string devEUI) + string devEUI, + CancellationToken cancellationToken) { DevEui parsedDevEui; @@ -78,10 +89,10 @@ public async Task Run( var c2dMessage = JsonConvert.DeserializeObject(requestBody); c2dMessage.DevEUI = parsedDevEui; - return await SendCloudToDeviceMessageImplementationAsync(parsedDevEui, c2dMessage); + return await SendCloudToDeviceMessageImplementationAsync(parsedDevEui, c2dMessage, cancellationToken); } - public async Task SendCloudToDeviceMessageImplementationAsync(DevEui devEUI, LoRaCloudToDeviceMessage c2dMessage) + public async Task SendCloudToDeviceMessageImplementationAsync(DevEui devEUI, LoRaCloudToDeviceMessage c2dMessage, CancellationToken cancellationToken) { if (c2dMessage == null) { @@ -96,7 +107,7 @@ public async Task SendCloudToDeviceMessageImplementationAsync(Dev var cachedPreferredGateway = LoRaDevicePreferredGateway.LoadFromCache(this.cacheStore, devEUI); if (cachedPreferredGateway != null && !string.IsNullOrEmpty(cachedPreferredGateway.GatewayID)) { - return await SendMessageViaDirectMethodAsync(cachedPreferredGateway.GatewayID, devEUI, c2dMessage); + return await SendMessageViaDirectMethodOrPubSubAsync(cachedPreferredGateway.GatewayID, devEUI, c2dMessage, cancellationToken); } var queryText = $"SELECT * FROM devices WHERE deviceId = '{devEUI}'"; @@ -137,7 +148,7 @@ public async Task SendCloudToDeviceMessageImplementationAsync(Dev var preferredGateway = new LoRaDevicePreferredGateway(gatewayID, 0); _ = LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEUI, preferredGateway, onlyIfNotExists: true); - return await SendMessageViaDirectMethodAsync(gatewayID, devEUI, c2dMessage); + return await SendMessageViaDirectMethodOrPubSubAsync(gatewayID, devEUI, c2dMessage, cancellationToken); } // class c device that did not send a single upstream message @@ -162,6 +173,7 @@ private async Task SendMessageViaCloudToDeviceMessageAsync(DevEui using var message = new Message(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(c2dMessage))); message.MessageId = string.IsNullOrEmpty(c2dMessage.MessageId) ? Guid.NewGuid().ToString() : c2dMessage.MessageId; + // class a devices only listen for 1-2 seconds, so we send to a queue on the device - we don't care about this for redis try { await this.serviceClient.SendAsync(devEUI.ToString(), message); @@ -188,20 +200,44 @@ private async Task SendMessageViaCloudToDeviceMessageAsync(DevEui } } - private async Task SendMessageViaDirectMethodAsync( + private async Task SendMessageViaDirectMethodOrPubSubAsync( string preferredGatewayID, DevEui devEUI, - LoRaCloudToDeviceMessage c2dMessage) + LoRaCloudToDeviceMessage c2dMessage, + CancellationToken cancellationToken) { try { var method = new CloudToDeviceMethod(LoraKeysManagerFacadeConstants.CloudToDeviceMessageMethodName, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); - _ = method.SetPayloadJson(JsonConvert.SerializeObject(c2dMessage)); + var jsonContent = JsonConvert.SerializeObject(c2dMessage); + _ = method.SetPayloadJson(jsonContent); - var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method); - if (HttpUtilities.IsSuccessStatusCode(res.Status)) + if (await edgeDeviceGetter.IsEdgeDeviceAsync(preferredGatewayID, cancellationToken)) { - this.log.LogInformation("Direct method call to {gatewayID} and {devEUI} succeeded with {statusCode}", preferredGatewayID, devEUI, res.Status); + var res = await this.serviceClient.InvokeDeviceMethodAsync(preferredGatewayID, LoraKeysManagerFacadeConstants.NetworkServerModuleId, method, cancellationToken); + if (HttpUtilities.IsSuccessStatusCode(res.Status)) + { + this.log.LogInformation("Direct method call to {gatewayID} and {devEUI} succeeded with {statusCode}", preferredGatewayID, devEUI, res.Status); + + return new OkObjectResult(new SendCloudToDeviceMessageResult() + { + DevEui = devEUI, + MessageID = c2dMessage.MessageId, + ClassType = "C", + }); + } + + this.log.LogError("Direct method call to {gatewayID} failed with {statusCode}. Response: {response}", preferredGatewayID, res.Status, res.GetPayloadAsJson()); + + return new ObjectResult(res.GetPayloadAsJson()) + { + StatusCode = res.Status, + }; + } + else + { + await this.channelPublisher.PublishAsync(preferredGatewayID, new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, jsonContent)); + this.log.LogInformation("C2D message to {gatewayID} and {devEUI} published to Redis queue", preferredGatewayID, devEUI); return new OkObjectResult(new SendCloudToDeviceMessageResult() { @@ -210,13 +246,6 @@ private async Task SendMessageViaDirectMethodAsync( ClassType = "C", }); } - - this.log.LogError("Direct method call to {gatewayID} failed with {statusCode}. Response: {response}", preferredGatewayID, res.Status, res.GetPayloadAsJson()); - - return new ObjectResult(res.GetPayloadAsJson()) - { - StatusCode = res.Status, - }; } catch (JsonSerializationException ex) { diff --git a/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs b/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs index afc086e350..9e3da32d2c 100644 --- a/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs +++ b/LoRaEngine/LoraKeysManagerFacade/ServiceClientAdapter.cs @@ -3,6 +3,7 @@ namespace LoraKeysManagerFacade { + using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Devices; @@ -15,7 +16,10 @@ public ServiceClientAdapter(ServiceClient serviceClient) this.serviceClient = serviceClient ?? throw new System.ArgumentNullException(nameof(serviceClient)); } - public Task InvokeDeviceMethodAsync(string deviceId, string moduleId, CloudToDeviceMethod cloudToDeviceMethod) => this.serviceClient.InvokeDeviceMethodAsync(deviceId, moduleId, cloudToDeviceMethod); + public Task InvokeDeviceMethodAsync(string deviceId, + string moduleId, + CloudToDeviceMethod cloudToDeviceMethod, + CancellationToken cancellationToken) => this.serviceClient.InvokeDeviceMethodAsync(deviceId, moduleId, cloudToDeviceMethod, cancellationToken); public Task SendAsync(string deviceId, Message message) => this.serviceClient.SendAsync(deviceId, message); } diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs index 2c948b9a19..2b2840ebe5 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/AssemblyInfo.cs @@ -5,3 +5,4 @@ [assembly: InternalsVisibleTo("LoRaWan.Tests.Common")] [assembly: InternalsVisibleTo("LoRaWan.Tests.Unit")] [assembly: InternalsVisibleTo("LoRaWan.Tests.Integration")] +[assembly: InternalsVisibleTo("LoRaWan.Tests.Simulation")] diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs index e02470da97..776f3c11aa 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/BasicsStationNetworkServerStartup.cs @@ -14,6 +14,7 @@ namespace LoRaWan.NetworkServer.BasicsStation using LoRaTools.CommonAPI; using LoRaTools.NetworkServerDiscovery; using LoRaWan; + using LoRaWan.NetworkServer; using LoRaWan.NetworkServer.ADR; using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using LoRaWan.NetworkServer.BasicsStation.Processors; @@ -30,6 +31,7 @@ namespace LoRaWan.NetworkServer.BasicsStation using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.ApplicationInsights; using Prometheus; + using StackExchange.Redis; internal sealed class BasicsStationNetworkServerStartup { @@ -50,67 +52,67 @@ public void ConfigureServices(IServiceCollection services) var appInsightsKey = Configuration.GetValue("APPINSIGHTS_INSTRUMENTATIONKEY"); var useApplicationInsights = !string.IsNullOrEmpty(appInsightsKey); _ = services.AddLogging(loggingBuilder => - { - _ = loggingBuilder.ClearProviders(); - var logLevel = int.TryParse(NetworkServerConfiguration.LogLevel, NumberStyles.Integer, CultureInfo.InvariantCulture, out var logLevelNum) - ? (LogLevel)logLevelNum is var level && Enum.IsDefined(typeof(LogLevel), level) ? level : throw new InvalidCastException() - : Enum.Parse(NetworkServerConfiguration.LogLevel, true); - - _ = loggingBuilder.SetMinimumLevel(logLevel); - _ = loggingBuilder.AddLoRaConsoleLogger(c => c.LogLevel = logLevel); - - if (NetworkServerConfiguration.LogToTcp) - { - _ = loggingBuilder.AddTcpLogger(new TcpLoggerConfiguration(logLevel, NetworkServerConfiguration.LogToTcpAddress, - NetworkServerConfiguration.LogToTcpPort, - NetworkServerConfiguration.GatewayID)); - } - if (NetworkServerConfiguration.LogToHub) - _ = loggingBuilder.AddIotHubLogger(c => c.LogLevel = logLevel); - - if (useApplicationInsights) - { - _ = loggingBuilder.AddApplicationInsights(appInsightsKey) - .AddFilter(string.Empty, logLevel); - _ = services.AddSingleton(_ => new TelemetryInitializer(NetworkServerConfiguration)); - } - }) - .AddMemoryCache() - .AddHttpClient() - .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) - .AddSingleton(NetworkServerConfiguration) - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton(loraModuleFactory) - .AddSingleton() - .AddSingleton>() - .AddSingleton() - .AddSingleton() - .AddSingleton(new LoRaDeviceCacheOptions { MaxUnobservedLifetime = TimeSpan.FromDays(10), RefreshInterval = TimeSpan.FromDays(2), ValidationInterval = TimeSpan.FromMinutes(10) }) - .AddTransient() - .AddTransient() - .AddSingleton() - .AddSingleton(new RegistryMetricTagBag(NetworkServerConfiguration)) - .AddSingleton(_ => new Meter(MetricRegistry.Namespace, MetricRegistry.Version)) - .AddHostedService(sp => - new MetricExporterHostedService( - new CompositeMetricExporter(useApplicationInsights ? new ApplicationInsightsMetricExporter(sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>()) : null, - new PrometheusMetricExporter(sp.GetRequiredService(), sp.GetRequiredService>())))); + { + _ = loggingBuilder.ClearProviders(); + var logLevel = int.TryParse(NetworkServerConfiguration.LogLevel, NumberStyles.Integer, CultureInfo.InvariantCulture, out var logLevelNum) + ? (LogLevel)logLevelNum is var level && Enum.IsDefined(typeof(LogLevel), level) ? level : throw new InvalidCastException() + : Enum.Parse(NetworkServerConfiguration.LogLevel, true); + + _ = loggingBuilder.SetMinimumLevel(logLevel); + _ = loggingBuilder.AddLoRaConsoleLogger(c => c.LogLevel = logLevel); + + if (NetworkServerConfiguration.LogToTcp) + { + _ = loggingBuilder.AddTcpLogger(new TcpLoggerConfiguration(logLevel, NetworkServerConfiguration.LogToTcpAddress, + NetworkServerConfiguration.LogToTcpPort, + NetworkServerConfiguration.GatewayID)); + } + if (NetworkServerConfiguration.LogToHub) + _ = loggingBuilder.AddIotHubLogger(c => c.LogLevel = logLevel); + + if (useApplicationInsights) + { + _ = loggingBuilder.AddApplicationInsights(appInsightsKey) + .AddFilter(string.Empty, logLevel); + _ = services.AddSingleton(_ => new TelemetryInitializer(NetworkServerConfiguration)); + } + }) + .AddMemoryCache() + .AddHttpClient() + .AddApiClient(NetworkServerConfiguration, ApiVersion.LatestVersion) + .AddSingleton(NetworkServerConfiguration) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(loraModuleFactory) + .AddSingleton() + .AddSingleton>() + .AddSingleton() + .AddSingleton() + .AddSingleton(new LoRaDeviceCacheOptions { MaxUnobservedLifetime = TimeSpan.FromDays(10), RefreshInterval = TimeSpan.FromDays(2), ValidationInterval = TimeSpan.FromMinutes(10) }) + .AddTransient() + .AddTransient() + .AddSingleton() + .AddSingleton(new RegistryMetricTagBag(NetworkServerConfiguration)) + .AddSingleton(_ => new Meter(MetricRegistry.Namespace, MetricRegistry.Version)) + .AddHostedService(sp => + new MetricExporterHostedService( + new CompositeMetricExporter(useApplicationInsights ? new ApplicationInsightsMetricExporter(sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>()) : null, + new PrometheusMetricExporter(sp.GetRequiredService(), sp.GetRequiredService>())))); if (useApplicationInsights) { @@ -124,6 +126,15 @@ public void ConfigureServices(IServiceCollection services) if (NetworkServerConfiguration.ClientCertificateMode is not ClientCertificateMode.NoCertificate) _ = services.AddSingleton(); + + _ = NetworkServerConfiguration.RunningAsIoTEdgeModule + ? services.AddSingleton() + : services.AddHostedService() + .AddSingleton() + .AddSingleton(sp => + new RedisRemoteCallListener(ConnectionMultiplexer.Connect(NetworkServerConfiguration.RedisConnectionString), + sp.GetRequiredService>(), + sp.GetRequiredService())); } #pragma warning disable CA1822 // Mark members as static diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs index 94190cec4d..2659bccb38 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/BasicsStation/ModuleConnection/ModuleConnectionHost.cs @@ -3,8 +3,9 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection { - using LoRaTools; using LoRaTools.Utils; + using LoRaTools; + using LoRaWan.NetworkServer; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Client.Exceptions; using Microsoft.Azure.Devices.Shared; @@ -12,43 +13,34 @@ namespace LoRaWan.NetworkServer.BasicsStation.ModuleConnection using System; using System.Configuration; using System.Diagnostics.Metrics; - using System.Net; - using System.Text.Json; using System.Threading; using System.Threading.Tasks; - public sealed class ModuleConnectionHost : IAsyncDisposable + internal sealed class ModuleConnectionHost : IAsyncDisposable { private const string LnsVersionPropertyName = "LnsVersion"; private readonly NetworkServerConfiguration networkServerConfiguration; - private readonly IClassCDeviceMessageSender classCMessageSender; - private readonly ILoRaDeviceRegistry loRaDeviceRegistry; private readonly LoRaDeviceAPIServiceBase loRaDeviceAPIService; + private readonly ILnsRemoteCallHandler lnsRemoteCallHandler; private readonly ILogger logger; private readonly Counter unhandledExceptionCount; - private readonly Counter forceClosedConnections; private ILoraModuleClient loRaModuleClient; private readonly ILoRaModuleClientFactory loRaModuleClientFactory; - public const string ClosedConnectionLog = "Device connection was closed "; - public ModuleConnectionHost( NetworkServerConfiguration networkServerConfiguration, - IClassCDeviceMessageSender defaultClassCDevicesMessageSender, ILoRaModuleClientFactory loRaModuleClientFactory, - ILoRaDeviceRegistry loRaDeviceRegistry, LoRaDeviceAPIServiceBase loRaDeviceAPIService, + ILnsRemoteCallHandler lnsRemoteCallHandler, ILogger logger, Meter meter) { this.networkServerConfiguration = networkServerConfiguration ?? throw new ArgumentNullException(nameof(networkServerConfiguration)); - this.classCMessageSender = defaultClassCDevicesMessageSender ?? throw new ArgumentNullException(nameof(defaultClassCDevicesMessageSender)); - this.loRaDeviceRegistry = loRaDeviceRegistry ?? throw new ArgumentNullException(nameof(loRaDeviceRegistry)); this.loRaDeviceAPIService = loRaDeviceAPIService ?? throw new ArgumentNullException(nameof(loRaDeviceAPIService)); + this.lnsRemoteCallHandler = lnsRemoteCallHandler ?? throw new ArgumentNullException(nameof(lnsRemoteCallHandler)); this.loRaModuleClientFactory = loRaModuleClientFactory ?? throw new ArgumentNullException(nameof(loRaModuleClientFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.unhandledExceptionCount = (meter ?? throw new ArgumentNullException(nameof(meter))).CreateCounter(MetricRegistry.UnhandledExceptions); - this.forceClosedConnections = meter.CreateCounter(MetricRegistry.ForceClosedClientConnections); } public async Task CreateAsync(CancellationToken cancellationToken) @@ -91,28 +83,37 @@ internal async Task InitModuleAsync(CancellationToken cancellationToken) await this.loRaModuleClient.SetMethodDefaultHandlerAsync(OnDirectMethodCalled, null); } + // handlers on device -- to be replaced with redis subscriber internal async Task OnDirectMethodCalled(MethodRequest methodRequest, object userContext) { if (methodRequest == null) throw new ArgumentNullException(nameof(methodRequest)); try { + using var cts = methodRequest.ResponseTimeout is { } someResponseTimeout ? new CancellationTokenSource(someResponseTimeout) : null; + var token = cts?.Token ?? CancellationToken.None; + + // Mapping via the constants for backwards compatibility. + LnsRemoteCall lnsRemoteCall; if (string.Equals(Constants.CloudToDeviceClearCache, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return await ClearCacheAsync(); + lnsRemoteCall = new LnsRemoteCall(RemoteCallKind.ClearCache, null); } else if (string.Equals(Constants.CloudToDeviceCloseConnection, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return await CloseConnectionAsync(methodRequest); + lnsRemoteCall = new LnsRemoteCall(RemoteCallKind.CloseConnection, methodRequest.DataAsJson); } else if (string.Equals(Constants.CloudToDeviceDecoderElementName, methodRequest.Name, StringComparison.OrdinalIgnoreCase)) { - return await SendCloudToDeviceMessageAsync(methodRequest); + lnsRemoteCall = new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, methodRequest.DataAsJson); + } + else + { + throw new LoRaProcessingException($"Unknown direct method called: {methodRequest.Name}"); } - this.logger.LogError($"Unknown direct method called: {methodRequest.Name}"); - - return new MethodResponse((int)HttpStatusCode.BadRequest); + var statusCode = await lnsRemoteCallHandler.ExecuteAsync(lnsRemoteCall, token); + return new MethodResponse((int)statusCode); } catch (Exception ex) when (ExceptionFilterUtility.False(() => this.logger.LogError(ex, $"An exception occurred on a direct method call: {ex}"), () => this.unhandledExceptionCount.Add(1))) @@ -121,87 +122,6 @@ internal async Task OnDirectMethodCalled(MethodRequest methodReq } } - private async Task SendCloudToDeviceMessageAsync(MethodRequest methodRequest) - { - if (!string.IsNullOrEmpty(methodRequest.DataAsJson)) - { - ReceivedLoRaCloudToDeviceMessage c2d = null; - - try - { - c2d = JsonSerializer.Deserialize(methodRequest.DataAsJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch (JsonException ex) - { - this.logger.LogError($"Impossible to parse Json for c2d message for device {c2d?.DevEUI}, error: {ex}"); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); - this.logger.LogDebug($"received cloud to device message from direct method: {methodRequest.DataAsJson}"); - - using var cts = methodRequest.ResponseTimeout.HasValue ? new CancellationTokenSource(methodRequest.ResponseTimeout.Value) : null; - - if (await this.classCMessageSender.SendAsync(c2d, cts?.Token ?? CancellationToken.None)) - { - return new MethodResponse((int)HttpStatusCode.OK); - } - } - - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - private async Task ClearCacheAsync() - { - await this.loRaDeviceRegistry.ResetDeviceCacheAsync(); - return new MethodResponse((int)HttpStatusCode.OK); - } - - private async Task CloseConnectionAsync(MethodRequest methodRequest) - { - ReceivedLoRaCloudToDeviceMessage c2d = null; - - try - { - c2d = methodRequest.DataAsJson is { } json ? JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null; - } - catch (JsonException ex) - { - this.logger.LogError(ex, "Unable to parse Json for direct method '{MethodName}' for device '{DevEui}', message id '{MessageId}'", methodRequest.Name, c2d?.DevEUI, c2d?.MessageId); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - if (c2d == null) - { - this.logger.LogError("Missing payload for direct method '{MethodName}'", methodRequest.Name); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - if (c2d.DevEUI == null) - { - this.logger.LogError("DevEUI missing, cannot identify device to close connection for; message Id '{MessageId}'", c2d.MessageId); - return new MethodResponse((int)HttpStatusCode.BadRequest); - } - - using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); - - var loRaDevice = await this.loRaDeviceRegistry.GetDeviceByDevEUIAsync(c2d.DevEUI.Value); - if (loRaDevice == null) - { - this.logger.LogError("Could not retrieve LoRa device; message id '{MessageId}'", c2d.MessageId); - return new MethodResponse((int)HttpStatusCode.NotFound); - } - - loRaDevice.IsConnectionOwner = false; - using var cts = methodRequest.ResponseTimeout is { } timeout ? new CancellationTokenSource(timeout) : null; - await loRaDevice.CloseConnectionAsync(cts?.Token ?? CancellationToken.None, force: true); - - this.logger.LogInformation(ClosedConnectionLog + "from gateway with id '{GatewayId}', message id '{MessageId}'", this.networkServerConfiguration.GatewayID, c2d.MessageId); - this.forceClosedConnections.Add(1); - - return new MethodResponse((int)HttpStatusCode.OK); - } - /// /// Method to update the desired properties. /// We only want to update the auth code if the facadeUri was performed. diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs new file mode 100644 index 0000000000..523ecf2a5e --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/CloudControlHost.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.NetworkServer +{ + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Hosting; + + internal class CloudControlHost : IHostedService + { + private readonly ILnsRemoteCallListener lnsRemoteCallListener; + private readonly ILnsRemoteCallHandler lnsRemoteCallHandler; + private readonly string[] subscriptionChannels; + + public CloudControlHost(ILnsRemoteCallListener lnsRemoteCallListener, + ILnsRemoteCallHandler lnsRemoteCallHandler, + NetworkServerConfiguration networkServerConfiguration) + { + this.lnsRemoteCallListener = lnsRemoteCallListener; + this.lnsRemoteCallHandler = lnsRemoteCallHandler; + this.subscriptionChannels = new string[] { networkServerConfiguration.GatewayID, Constants.CloudToDeviceClearCache }; + } + + + public Task StartAsync(CancellationToken cancellationToken) => + Task.WhenAll(this.subscriptionChannels.Select(c => this.lnsRemoteCallListener.SubscribeAsync(c, + remoteCall => this.lnsRemoteCallHandler.ExecuteAsync(remoteCall, cancellationToken), + cancellationToken))); + + public Task StopAsync(CancellationToken cancellationToken) => + Task.WhenAll(this.subscriptionChannels.Select(c => this.lnsRemoteCallListener.UnsubscribeAsync(c, cancellationToken))); + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallHandler.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallHandler.cs new file mode 100644 index 0000000000..ba4eb87591 --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallHandler.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.NetworkServer +{ + using System.Diagnostics.Metrics; + using System.Net; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using Microsoft.Extensions.Logging; + + internal interface ILnsRemoteCallHandler + { + Task ExecuteAsync(LnsRemoteCall lnsRemoteCall, CancellationToken cancellationToken); + } + + internal sealed class LnsRemoteCallHandler : ILnsRemoteCallHandler + { + internal const string ClosedConnectionLog = "Device connection was closed "; + private static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + private readonly NetworkServerConfiguration networkServerConfiguration; + private readonly IClassCDeviceMessageSender classCDeviceMessageSender; + private readonly ILoRaDeviceRegistry loRaDeviceRegistry; + private readonly ILogger logger; + private readonly Counter forceClosedConnections; + + public LnsRemoteCallHandler(NetworkServerConfiguration networkServerConfiguration, + IClassCDeviceMessageSender classCDeviceMessageSender, + ILoRaDeviceRegistry loRaDeviceRegistry, + ILogger logger, + Meter meter) + { + this.networkServerConfiguration = networkServerConfiguration; + this.classCDeviceMessageSender = classCDeviceMessageSender; + this.loRaDeviceRegistry = loRaDeviceRegistry; + this.logger = logger; + this.forceClosedConnections = meter.CreateCounter(MetricRegistry.ForceClosedClientConnections); + } + + public Task ExecuteAsync(LnsRemoteCall lnsRemoteCall, CancellationToken cancellationToken) + { + return lnsRemoteCall.Kind switch + { + RemoteCallKind.CloudToDeviceMessage => SendCloudToDeviceMessageAsync(lnsRemoteCall.JsonData, cancellationToken), + RemoteCallKind.ClearCache => ClearCacheAsync(), + RemoteCallKind.CloseConnection => CloseConnectionAsync(lnsRemoteCall.JsonData, cancellationToken), + _ => throw new System.NotImplementedException(), + }; + } + + private async Task SendCloudToDeviceMessageAsync(string json, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(json)) + { + ReceivedLoRaCloudToDeviceMessage c2d; + + try + { + c2d = JsonSerializer.Deserialize(json, JsonSerializerOptions); + } + catch (JsonException ex) + { + this.logger.LogError(ex, $"Impossible to parse Json for c2d message, error: '{ex}'"); + return HttpStatusCode.BadRequest; + } + + using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); + this.logger.LogDebug($"received cloud to device message from direct method: {json}"); + + if (await this.classCDeviceMessageSender.SendAsync(c2d, cancellationToken)) + return HttpStatusCode.OK; + } + + return HttpStatusCode.BadRequest; + } + + private async Task CloseConnectionAsync(string json, CancellationToken cancellationToken) + { + ReceivedLoRaCloudToDeviceMessage c2d; + + try + { + c2d = JsonSerializer.Deserialize(json, JsonSerializerOptions); + } + catch (JsonException ex) + { + this.logger.LogError(ex, "Unable to parse Json when attempting to close the connection."); + return HttpStatusCode.BadRequest; + } + + if (c2d == null) + { + this.logger.LogError("Missing payload when attempting to close the connection."); + return HttpStatusCode.BadRequest; + } + + if (c2d.DevEUI == null) + { + this.logger.LogError("DevEUI missing, cannot identify device to close connection for; message Id '{MessageId}'", c2d.MessageId); + return HttpStatusCode.BadRequest; + } + + using var scope = this.logger.BeginDeviceScope(c2d.DevEUI); + + var loRaDevice = await this.loRaDeviceRegistry.GetDeviceByDevEUIAsync(c2d.DevEUI.Value); + if (loRaDevice == null) + { + this.logger.LogError("Could not retrieve LoRa device; message id '{MessageId}'", c2d.MessageId); + return HttpStatusCode.NotFound; + } + + loRaDevice.IsConnectionOwner = false; + await loRaDevice.CloseConnectionAsync(cancellationToken, force: true); + + this.logger.LogInformation(ClosedConnectionLog + "from gateway with id '{GatewayId}', message id '{MessageId}'", this.networkServerConfiguration.GatewayID, c2d.MessageId); + this.forceClosedConnections.Add(1); + + return HttpStatusCode.OK; + } + + private async Task ClearCacheAsync() + { + await this.loRaDeviceRegistry.ResetDeviceCacheAsync(); + return HttpStatusCode.OK; + } + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs new file mode 100644 index 0000000000..5ade6cbcda --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LnsRemoteCallListener.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.NetworkServer +{ + using System; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using StackExchange.Redis; + using LoRaTools; + using Microsoft.Extensions.Logging; + using System.Diagnostics.Metrics; + + internal interface ILnsRemoteCallListener + { + Task SubscribeAsync(string lns, Func function, CancellationToken cancellationToken); + + Task UnsubscribeAsync(string lns, CancellationToken cancellationToken); + } + + internal sealed class RedisRemoteCallListener : ILnsRemoteCallListener + { + private readonly ConnectionMultiplexer redis; + private readonly ILogger logger; + private readonly Counter unhandledExceptionCount; + + public RedisRemoteCallListener(ConnectionMultiplexer redis, ILogger logger, Meter meter) + { + this.redis = redis; + this.logger = logger; + this.unhandledExceptionCount = meter.CreateCounter(MetricRegistry.UnhandledExceptions); + } + + // Cancellation token to be passed when/if a future update to SubscribeAsync is allowing to use it + public async Task SubscribeAsync(string lns, Func function, CancellationToken cancellationToken) + { + var channelMessage = await this.redis.GetSubscriber().SubscribeAsync(lns); + channelMessage.OnMessage(value => + { + try + { + if (value is { Message: { } m } && !m.IsNullOrEmpty) + { + var lnsRemoteCall = JsonSerializer.Deserialize(m.ToString()) ?? throw new InvalidOperationException("Deserialization produced an empty LnsRemoteCall."); + return function(lnsRemoteCall); + } + else + { + throw new ArgumentNullException(nameof(value)); + } + } + catch (Exception ex) when (ExceptionFilterUtility.False(() => this.logger.LogError(ex, $"An exception occurred when reacting to a Redis message: '{ex}'."), + () => this.unhandledExceptionCount.Add(1))) + { + throw; + } + }); + } + + // Cancellation token to be passed when/if a future update to UnsubscribeAsync is allowing to use it + public async Task UnsubscribeAsync(string lns, CancellationToken cancellationToken) + { + await this.redis.GetSubscriber().UnsubscribeAsync(lns); + } + } +} diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj index 866e61e4d8..8da7b21855 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/LoRaWan.NetworkServer.csproj @@ -22,6 +22,7 @@ + diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs index d9b156a63f..8b632167c1 100644 --- a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoRaWan.NetworkServer/NetworkServerConfiguration.cs @@ -131,10 +131,18 @@ public class NetworkServerConfiguration /// public string LnsVersion { get; private set; } + /// + /// Gets the connection string of Redis server for Pub/Sub functionality in Cloud only deployments. + /// + public string RedisConnectionString { get; private set; } + /// Specifies the pool size for upstream AMQP connection /// public uint IotHubConnectionPoolSize { get; internal set; } = 1; + /// + /// Specifies the Processing Delay in Milliseconds + /// public int ProcessingDelayInMilliseconds { get; set; } = Constants.DefaultProcessingDelayInMilliseconds; // Creates a new instance of NetworkServerConfiguration by reading values from environment variables @@ -144,12 +152,22 @@ public static NetworkServerConfiguration CreateFromEnvironmentVariables() // Create case insensitive dictionary from environment variables var envVars = new CaseInsensitiveEnvironmentVariables(Environment.GetEnvironmentVariables()); + config.ProcessingDelayInMilliseconds = envVars.GetEnvVar("PROCESSING_DELAY_IN_MS", config.ProcessingDelayInMilliseconds); + config.RunningAsIoTEdgeModule = !envVars.GetEnvVar("CLOUD_DEPLOYMENT", false); + + var iotHubHostName = envVars.GetEnvVar("IOTEDGE_IOTHUBHOSTNAME", envVars.GetEnvVar("IOTHUBHOSTNAME", string.Empty)); + config.IoTHubHostName = !string.IsNullOrEmpty(iotHubHostName) ? iotHubHostName : throw new InvalidOperationException("Either 'IOTEDGE_IOTHUBHOSTNAME' or 'IOTHUBHOSTNAME' environment variable should be populated"); - config.RunningAsIoTEdgeModule = !string.IsNullOrEmpty(envVars.GetEnvVar("IOTEDGE_APIVERSION", string.Empty)); - config.IoTHubHostName = envVars.GetEnvVar("IOTEDGE_IOTHUBHOSTNAME", string.Empty); config.GatewayHostName = envVars.GetEnvVar("IOTEDGE_GATEWAYHOSTNAME", string.Empty); - config.EnableGateway = envVars.GetEnvVar("ENABLE_GATEWAY", config.EnableGateway); - config.GatewayID = envVars.GetEnvVar("IOTEDGE_DEVICEID", string.Empty); + config.EnableGateway = envVars.GetEnvVar("ENABLE_GATEWAY", true); + if (!config.RunningAsIoTEdgeModule && config.EnableGateway) + { + throw new NotSupportedException("ENABLE_GATEWAY cannot be true if RunningAsIoTEdgeModule is false."); + } + + var gatewayId = envVars.GetEnvVar("IOTEDGE_DEVICEID", envVars.GetEnvVar("HOSTNAME", string.Empty)); + config.GatewayID = !string.IsNullOrEmpty(gatewayId) ? gatewayId : throw new InvalidOperationException("Either 'IOTEDGE_DEVICEID' or 'HOSTNAME' environment variable should be populated"); + config.HttpsProxy = envVars.GetEnvVar("HTTPS_PROXY", string.Empty); config.Rx2DataRate = envVars.GetEnvVar("RX2_DATR", -1) is var datrNum && (DataRateIndex)datrNum is var datr && Enum.IsDefined(datr) ? datr : null; config.Rx2Frequency = envVars.GetEnvVar("RX2_FREQ") is { } someFreq ? Hertz.Mega(someFreq) : null; @@ -184,6 +202,10 @@ public static NetworkServerConfiguration CreateFromEnvironmentVariables() ? size : throw new NotSupportedException($"'IOTHUB_CONNECTION_POOL_SIZE' needs to be between 1 and {AmqpConnectionPoolSettings.AbsoluteMaxPoolSize}."); + config.RedisConnectionString = envVars.GetEnvVar("REDIS_CONNECTION_STRING", string.Empty); + if (!config.RunningAsIoTEdgeModule && string.IsNullOrEmpty(config.RedisConnectionString)) + throw new InvalidOperationException("'REDIS_CONNECTION_STRING' can't be empty if running network server as part of a cloud only deployment."); + return config; } } diff --git a/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoraTools/LnsRemoteCall.cs b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoraTools/LnsRemoteCall.cs new file mode 100644 index 0000000000..bec17ab930 --- /dev/null +++ b/LoRaEngine/modules/LoRaWanNetworkSrvModule/LoraTools/LnsRemoteCall.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaTools +{ + public sealed record LnsRemoteCall(RemoteCallKind Kind, string? JsonData); + + public enum RemoteCallKind + { + CloudToDeviceMessage, + ClearCache, + CloseConnection + } +} diff --git a/Tests/E2E/LoRaAPIHelper.cs b/Tests/Common/LoRaAPIHelper.cs similarity index 98% rename from Tests/E2E/LoRaAPIHelper.cs rename to Tests/Common/LoRaAPIHelper.cs index a70f2b8f47..53df906559 100644 --- a/Tests/E2E/LoRaAPIHelper.cs +++ b/Tests/Common/LoRaAPIHelper.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -namespace LoRaWan.Tests.E2E +namespace LoRaWan.Tests.Common { using System; using System.Net.Http; diff --git a/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs b/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs index be5213783c..70c528f1b4 100644 --- a/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs +++ b/Tests/Integration/DeduplicationWithRedisIntegrationTests.cs @@ -6,9 +6,11 @@ namespace LoRaWan.Tests.Integration { using System; + using System.Threading; using System.Threading.Tasks; using LoraKeysManagerFacade; using LoraKeysManagerFacade.FunctionBundler; + using LoRaTools; using LoRaWan.Tests.Common; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Azure.Devices; @@ -24,6 +26,8 @@ public sealed class DeduplicationTestWithRedis : IClassFixture, ID private readonly ILoRaDeviceCacheStore cache; private readonly Mock serviceClientMock; private readonly TelemetryConfiguration telemetryConfiguration; + private readonly Mock edgeDeviceGetter; + private readonly Mock channelPublisher; private readonly DeduplicationExecutionItem deduplicationExecutionItem; public DeduplicationTestWithRedis(RedisFixture redis) @@ -33,18 +37,29 @@ public DeduplicationTestWithRedis(RedisFixture redis) this.cache = new LoRaDeviceCacheRedisStore(redis.Database); this.serviceClientMock = new Mock(); this.telemetryConfiguration = new TelemetryConfiguration(); - this.deduplicationExecutionItem = new DeduplicationExecutionItem(this.cache, this.serviceClientMock.Object, this.telemetryConfiguration); + this.edgeDeviceGetter = new Mock(); + this.channelPublisher = new Mock(); + this.deduplicationExecutionItem = new DeduplicationExecutionItem(this.cache, + this.serviceClientMock.Object, + this.edgeDeviceGetter.Object, + this.channelPublisher.Object, + this.telemetryConfiguration); } [Theory] - [InlineData("gateway1", 1, "gateway1", 1)] - [InlineData("gateway1", 1, "gateway1", 2)] - [InlineData("gateway1", 1, "gateway2", 1)] - [InlineData("gateway1", 1, "gateway2", 2)] - public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Duplicates(string gateway1, uint fcnt1, string gateway2, uint fcnt2) + [InlineData("gateway1", 1, "gateway1", 1, true)] + [InlineData("gateway1", 1, "gateway1", 2, true)] + [InlineData("gateway1", 1, "gateway2", 1, true)] + [InlineData("gateway1", 1, "gateway2", 2, true)] + [InlineData("gateway1", 1, "gateway1", 1, false)] + [InlineData("gateway1", 1, "gateway1", 2, false)] + [InlineData("gateway1", 1, "gateway2", 1, false)] + [InlineData("gateway1", 1, "gateway2", 2, false)] + public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Duplicates_Direct_Method_Or_Pub_Sub(string gateway1, uint fcnt1, string gateway2, uint fcnt2, bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); this.serviceClientMock.Setup( - x => x.InvokeDeviceMethodAsync(It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny())) + x => x.InvokeDeviceMethodAsync(It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny(), It.IsAny())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); var devEUI = TestEui.GenerateDevEui(); @@ -81,10 +96,18 @@ public async Task When_Called_Multiple_Times_With_Same_Device_Should_Detect_Dupl Assert.Equal(FunctionBundlerExecutionState.Continue, res2); Assert.False(pipeline2.Result.DeduplicationResult.IsDuplicate); - // gateway1 should be notified that it needs to drop connection for the device - this.serviceClientMock.Verify(x => x.InvokeDeviceMethodAsync(gateway1, LoraKeysManagerFacadeConstants.NetworkServerModuleId, - It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(devEUI.ToString())))); + if (isEdgeDevice) + { + // gateway1 should be notified that it needs to drop connection for the device + this.serviceClientMock.Verify(x => x.InvokeDeviceMethodAsync(gateway1, LoraKeysManagerFacadeConstants.NetworkServerModuleId, + It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection + && m.GetPayloadAsJson().Contains(devEUI.ToString())), It.IsAny())); + } + else + { + this.channelPublisher.Verify(x => x.PublishAsync(gateway1, It.Is(c => c.Kind == RemoteCallKind.CloseConnection))); + this.serviceClientMock.VerifyNoOtherCalls(); + } } } diff --git a/Tests/Integration/RedisChannelPublisherTests.cs b/Tests/Integration/RedisChannelPublisherTests.cs new file mode 100644 index 0000000000..c4ea45ca0c --- /dev/null +++ b/Tests/Integration/RedisChannelPublisherTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Integration +{ + using System; + using System.Threading.Tasks; + using LoRaWan.Tests.Common; + using Xunit; + using LoraKeysManagerFacade; + using Xunit.Abstractions; + using Microsoft.Extensions.Logging.Abstractions; + using StackExchange.Redis; + using Moq; + using LoRaTools; + using System.Text.Json; + + [Collection(RedisFixture.CollectionName)] + public class RedisChannelPublisherTests : IClassFixture + { + private readonly IChannelPublisher channelPublisher; + private readonly ITestOutputHelper testOutputHelper; + private readonly ConnectionMultiplexer redis; + + public RedisChannelPublisherTests(RedisFixture redis, ITestOutputHelper testOutputHelper) + { + if (redis is null) throw new ArgumentNullException(nameof(redis)); + this.channelPublisher = new RedisChannelPublisher(redis.Redis, NullLogger.Instance); + this.testOutputHelper = testOutputHelper; + this.redis = redis.Redis; + } + + [Fact] + public async Task Publish_Aysnc() + { + // arrange + var message = new LnsRemoteCall(RemoteCallKind.CloseConnection, "test message"); + var serializedMessage = JsonSerializer.Serialize(message); + var channel = "channel1"; + var assert = new Mock>(); + this.testOutputHelper.WriteLine("Publishing message..."); + (await this.redis.GetSubscriber().SubscribeAsync(channel)).OnMessage(assert.Object); + + // act + await this.channelPublisher.PublishAsync(channel, message); + + // assert + await assert.RetryVerifyAsync(a => a.Invoke(It.Is(actual => actual.Message == serializedMessage)), Times.Once); + } + } +} diff --git a/Tests/Integration/RedisFixture.cs b/Tests/Integration/RedisFixture.cs index e80bec551b..10d8f7a3bd 100644 --- a/Tests/Integration/RedisFixture.cs +++ b/Tests/Integration/RedisFixture.cs @@ -27,11 +27,10 @@ public class RedisFixture : IAsyncLifetime private const int RedisPort = 6001; private static readonly string TestContainerName = ContainerName + RedisPort; - private ConnectionMultiplexer redis; - private string containerId; public IDatabase Database { get; set; } + public ConnectionMultiplexer Redis { get; set; } private async Task StartRedisContainer() { @@ -127,14 +126,15 @@ public async Task InitializeAsync() var redisConnectionString = $"localhost:{RedisPort}"; try { - this.redis = await ConnectionMultiplexer.ConnectAsync(redisConnectionString); - Database = this.redis.GetDatabase(); + this.Redis = await ConnectionMultiplexer.ConnectAsync(redisConnectionString); + Database = this.Redis.GetDatabase(); } catch (Exception ex) { throw new InvalidOperationException($"Failed to connect to redis at '{redisConnectionString}'. If running locally with docker: run 'docker run -d -p 6379:6379 redis'. If running in Azure DevOps: run redis in docker.", ex); } } + public async Task DisposeAsync() { @@ -157,8 +157,8 @@ public async Task DisposeAsync() } } - this.redis?.Dispose(); - this.redis = null; + this.Redis?.Dispose(); + this.Redis = null; } } } diff --git a/Tests/Integration/RedisRemoteCallListenerTests.cs b/Tests/Integration/RedisRemoteCallListenerTests.cs new file mode 100644 index 0000000000..bb872046b8 --- /dev/null +++ b/Tests/Integration/RedisRemoteCallListenerTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Integration +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using LoRaTools; + using LoRaWan.NetworkServer; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Moq; + using StackExchange.Redis; + using Xunit; + using Xunit.Sdk; + + [Collection(RedisFixture.CollectionName)] + public sealed class RedisRemoteCallListenerTests : IClassFixture + { + private readonly ConnectionMultiplexer redis; + private readonly Mock> logger; + private readonly RedisRemoteCallListener subject; + + public RedisRemoteCallListenerTests(RedisFixture redisFixture) + { + this.redis = redisFixture.Redis; + this.logger = new Mock>(); + this.subject = new RedisRemoteCallListener(this.redis, this.logger.Object, TestMeter.Instance); + } + + [Fact] + public async Task Subscribe_Receives_Message() + { + // arrange + var lnsName = "some-lns"; + var remoteCall = new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, "somejsondata"); + var function = new Mock>(); + + // act + await this.subject.SubscribeAsync(lnsName, function.Object, CancellationToken.None); + await PublishAsync(lnsName, remoteCall); + + // assert + await function.RetryVerifyAsync(a => a.Invoke(remoteCall), Times.Once); + } + + [Fact] + public async Task Subscribe_On_Different_Channel_Does_Not_Receive_Message() + { + // arrange + var function = new Mock>(); + + // act + await this.subject.SubscribeAsync("lns-1", function.Object, CancellationToken.None); + await PublishAsync("lns-2", new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, null)); + + // assert + await function.RetryVerifyAsync(a => a.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UnsubscribeAsync_Unsubscribes_Successfully() + { + // arrange + var lns = "lns-1"; + var function = new Mock>(); + await this.subject.SubscribeAsync(lns, function.Object, CancellationToken.None); + + // act + await this.subject.UnsubscribeAsync(lns, CancellationToken.None); + await PublishAsync(lns, new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, null)); + + // assert + function.Verify(a => a.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SubscribeAsync_Exceptions_Are_Tracked() + { + // arrange + var lns = "lns-1"; + var function = new Mock>(); + + // act + await this.subject.SubscribeAsync(lns, function.Object, CancellationToken.None); + await this.redis.GetSubscriber().PublishAsync(lns, string.Empty); + + // assert + var invocation = await RetryAssertSingleAsync(this.logger.GetLogInvocations()); + _ = Assert.IsType(invocation.Exception); + } + + private async Task PublishAsync(string channel, LnsRemoteCall lnsRemoteCall) + { + await this.redis.GetSubscriber().PublishAsync(channel, JsonSerializer.Serialize(lnsRemoteCall)); + } + + private static async Task RetryAssertSingleAsync(IEnumerable sequence, + int numberOfRetries = 5, + TimeSpan? delay = null) + { + var retryDelay = delay ?? TimeSpan.FromMilliseconds(50); + for (var i = 0; i < numberOfRetries + 1; ++i) + { + try + { + var result = Assert.Single(sequence); + return result; + } + catch (SingleException) when (i < numberOfRetries) + { + // assertion does not yet pass, retry once more. + await Task.Delay(retryDelay); + continue; + } + } + + throw new InvalidOperationException("asdfasdf"); + } + } +} diff --git a/Tests/Simulation/IntegrationTestFixtureSim.cs b/Tests/Simulation/IntegrationTestFixtureSim.cs index 3c96f03e8d..8db6a84272 100644 --- a/Tests/Simulation/IntegrationTestFixtureSim.cs +++ b/Tests/Simulation/IntegrationTestFixtureSim.cs @@ -8,6 +8,7 @@ namespace LoRaWan.Tests.Simulation using System.Globalization; using System.IO; using System.Linq; + using System.Threading.Tasks; using LoRaWan.NetworkServer; using LoRaWan.Tests.Common; using Newtonsoft.Json.Linq; @@ -23,6 +24,9 @@ public class IntegrationTestFixtureSim : IntegrationTestFixtureBase // Device1003_Simulated_ABP: used for ABP simulator public TestDeviceInfo Device1003_Simulated_ABP { get; private set; } + // Device1004_Simulated_ABP: used for ABP simulator + public TestDeviceInfo Device1004_Simulated_ABP { get; private set; } + private readonly List deviceRange1000_ABP = new List(); public IReadOnlyCollection DeviceRange1000_ABP => this.deviceRange1000_ABP; @@ -46,6 +50,12 @@ public class IntegrationTestFixtureSim : IntegrationTestFixtureBase public IReadOnlyCollection DeviceRange6000_OTAA_FullLoad { get; private set; } public IReadOnlyCollection DeviceRange9000_OTAA_FullLoad_DuplicationDrop { get; private set; } + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + LoRaAPIHelper.Initialize(Configuration.FunctionAppCode, Configuration.FunctionAppBaseUrl); + } + public override void SetupTestDevices() { var gatewayID = Environment.GetEnvironmentVariable("IOTEDGE_DEVICEID") ?? Configuration.LeafDeviceGatewayID; @@ -87,6 +97,19 @@ public override void SetupTestDevices() DevAddr = new DevAddr(0x00001003), }; + // Device1004_Simulated_ABP: used for simulator + Device1004_Simulated_ABP = new TestDeviceInfo() + { + DeviceID = "0000000000001004", + Deduplication = DeduplicationMode.Drop, + SensorDecoder = "DecoderValueSensor", + IsIoTHubDevice = true, + AppSKey = GetAppSessionKey(1004), + NwkSKey = GetNetworkSessionKey(1004), + DevAddr = new DevAddr(0x00001004), + ClassType = LoRaDeviceClassType.C + }; + var fileName = "EU863.json"; var jsonString = File.ReadAllText(fileName); diff --git a/Tests/Simulation/SimulatedCloudTests.cs b/Tests/Simulation/SimulatedCloudTests.cs new file mode 100644 index 0000000000..bd1a6db4e5 --- /dev/null +++ b/Tests/Simulation/SimulatedCloudTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Simulation +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + using LoRaTools.CommonAPI; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + using Xunit; + using Xunit.Abstractions; + using static MoreLinq.Extensions.RepeatExtension; + + [Trait("Category", "SkipWhenLiveUnitTesting")] + public sealed class SimulatedCloudTests : IntegrationTestBaseSim, IAsyncLifetime + { + private readonly List simulatedBasicsStations; + /// + /// A unique upstream message fragment is used for each uplink message to ensure + /// that there is no interference between test runs. + /// + private readonly string uniqueMessageFragment; + private readonly TestOutputLogger logger; + + public TestConfiguration Configuration { get; } = TestConfiguration.GetConfiguration(); + + public SimulatedCloudTests(IntegrationTestFixtureSim testFixture, ITestOutputHelper testOutputHelper) + : base(testFixture) + { + this.uniqueMessageFragment = Guid.NewGuid().ToString(); + this.logger = new TestOutputLogger(testOutputHelper); + this.simulatedBasicsStations = + testFixture.DeviceRange5000_BasicsStationSimulators + .Zip(Configuration.LnsEndpointsForSimulator.Repeat(), + (tdi, lnsNameToUrl) => new SimulatedBasicsStation(StationEui.Parse(tdi.DeviceID), lnsNameToUrl.Value)) + .ToList(); + + Assert.True(this.simulatedBasicsStations.Count % Configuration.LnsEndpointsForSimulator.Count == 0, "Since Basics Stations are round-robin distributed to LNS, we must have the same number of stations per LNS for well-defined test assertions."); + } + + [Fact] + public async Task Single_ABP_Simulated_Device_Sends_And_Receives_C2D() + { + var testDeviceInfo = TestFixtureSim.Device1004_Simulated_ABP; + LogTestStart(testDeviceInfo); + + const int messageCount = 5; + var device = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: this.simulatedBasicsStations, logger: this.logger); + + TestLogger.Log($"[INFO] Simulating send of {messageCount} messages from {device.LoRaDevice.DeviceID}"); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); + + // Now sending a c2d + var c2d = new LoRaCloudToDeviceMessage() + { + DevEUI = device.DevEUI, + MessageId = Guid.NewGuid().ToString(), + Fport = FramePorts.App23, + RawPayload = Convert.ToBase64String(new byte[] { 0xFF, 0x00 }), + }; + + TestLogger.Log($"[INFO] Using service API to send C2D message to device {device.LoRaDevice.DeviceID}"); + TestLogger.Log($"[INFO] {JsonConvert.SerializeObject(c2d, Formatting.None)}"); + + // send message using the SendCloudToDeviceMessage API endpoint + Assert.True(await LoRaAPIHelper.SendCloudToDeviceMessage(device.DevEUI, c2d)); + + var c2dLogMessage = $"{device.LoRaDevice.DeviceID}: received cloud to device message from direct method"; + TestLogger.Log($"[INFO] Searching for following log in LNS logs: '{c2dLogMessage}'"); + + var searchResults = await TestFixture.SearchNetworkServerModuleAsync( + messageBody => messageBody.StartsWith(c2dLogMessage, StringComparison.OrdinalIgnoreCase), + new SearchLogOptions(c2dLogMessage) + { + MaxAttempts = 1 + }); + + Assert.True(searchResults.Found, $"Did not find '{device.LoRaDevice.DeviceID}: C2D log: {c2dLogMessage}' in logs"); + + TestLogger.Log($"[INFO] Asserting all messages were received in IoT Hub for device {device.LoRaDevice.DeviceID}"); + await SimulationUtils.AssertIotHubMessageCountAsync(device, + messageCount, + this.uniqueMessageFragment, + this.logger, + this.simulatedBasicsStations.Count, + TestFixture.IoTHubMessages, + Configuration.LnsEndpointsForSimulator.Count); + } + + public async Task InitializeAsync() + { + await Task.WhenAll(from basicsStation in this.simulatedBasicsStations + select basicsStation.StartAsync()); + } + + public async Task DisposeAsync() + { + foreach (var basicsStation in this.simulatedBasicsStations) + { + try + { + await basicsStation.StopAndValidateAsync(); + basicsStation.Dispose(); + } + catch (Exception) + { + // Dispose all basics stations + } + } + } + } +} diff --git a/Tests/Simulation/SimulatedLoadTests.cs b/Tests/Simulation/SimulatedLoadTests.cs index 91b62eb7e5..6f7940b901 100644 --- a/Tests/Simulation/SimulatedLoadTests.cs +++ b/Tests/Simulation/SimulatedLoadTests.cs @@ -8,12 +8,8 @@ namespace LoRaWan.Tests.Simulation using System.Diagnostics; using System.Globalization; using System.Linq; - using System.Runtime.CompilerServices; using System.Security.Cryptography; - using System.Text; - using System.Text.Json; using System.Threading.Tasks; - using Azure.Messaging.EventHubs; using LoRaWan.Tests.Common; using Microsoft.Extensions.Logging; using NetworkServer; @@ -22,12 +18,10 @@ namespace LoRaWan.Tests.Simulation using static MoreLinq.Extensions.RepeatExtension; using static MoreLinq.Extensions.IndexExtension; using static MoreLinq.Extensions.TransposeExtension; - using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; [Trait("Category", "SkipWhenLiveUnitTesting")] public sealed class SimulatedLoadTests : IntegrationTestBaseSim, IAsyncLifetime { - private const double DownstreamDroppedMessagesTolerance = 0.02; private static readonly TimeSpan IntervalBetweenMessages = TimeSpan.FromSeconds(5); private readonly List simulatedBasicsStations; /// @@ -61,16 +55,22 @@ public async Task Five_Devices_Sending_Messages_At_Same_Time() // arrange const int messageCount = 2; - var simulatedDevices = InitializeSimulatedDevices(testDeviceInfo); + var simulatedDevices = SimulationUtils.InitializeSimulatedDevices(testDeviceInfo, this.simulatedBasicsStations, logger); Assert.NotEmpty(simulatedDevices); // act await Task.WhenAll(from device in simulatedDevices - select SendConfirmedUpstreamMessages(device, messageCount)); + select SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment)); // assert - await AssertIotHubMessageCountsAsync(simulatedDevices, messageCount); - AssertMessageAcknowledgements(simulatedDevices, messageCount); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedDevices, + messageCount, + this.uniqueMessageFragment, + this.logger, + this.simulatedBasicsStations.Count, + TestFixture.IoTHubMessages, + Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgements(simulatedDevices, messageCount); } [Fact] @@ -82,10 +82,10 @@ public async Task Single_ABP_Simulated_Device() const int messageCount = 5; var device = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: this.simulatedBasicsStations, logger: this.logger); - await SendConfirmedUpstreamMessages(device, messageCount); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); - await AssertIotHubMessageCountAsync(device, messageCount); - AssertMessageAcknowledgement(device, messageCount); + await SimulationUtils.AssertIotHubMessageCountAsync(device, messageCount, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgement(device, messageCount); } [Fact] @@ -97,23 +97,23 @@ public async Task Ensures_Disconnect_Happens_For_Losing_Gateway_When_Connection_ var messagesToSendEachLNS = 3; var simulatedDevice = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: new[] { this.simulatedBasicsStations.First() }, logger: this.logger); - await SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS); + await SimulationUtils.SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS, this.uniqueMessageFragment); await Task.Delay(messagesToSendEachLNS * IntervalBetweenMessages); _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( - x => !x.Contains(ModuleConnectionHost.ClosedConnectionLog, StringComparison.Ordinal), + x => !x.Contains(LnsRemoteCallHandler.ClosedConnectionLog, StringComparison.Ordinal), new SearchLogOptions("No connection switch should be logged") { TreatAsError = true }); // act: change basics station that the device is listened from and therefore the gateway it uses as well simulatedDevice.SimulatedBasicsStations = new[] { this.simulatedBasicsStations.Last() }; - await SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS); + await SimulationUtils.SendConfirmedUpstreamMessages(simulatedDevice, messagesToSendEachLNS, this.uniqueMessageFragment); // assert var expectedLnsToDropConnection = Configuration.LnsEndpointsForSimulator.First().Key; _ = await TestFixture.AssertNetworkServerModuleLogExistsAsync( - x => x.Contains(ModuleConnectionHost.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), - new SearchLogOptions($"{ModuleConnectionHost.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); - await AssertIotHubMessageCountAsync(simulatedDevice, messagesToSendEachLNS * 2); + x => x.Contains(LnsRemoteCallHandler.ClosedConnectionLog, StringComparison.Ordinal) && x.Contains(expectedLnsToDropConnection, StringComparison.Ordinal), + new SearchLogOptions($"{LnsRemoteCallHandler.ClosedConnectionLog} and {expectedLnsToDropConnection}") { TreatAsError = true }); + await SimulationUtils.AssertIotHubMessageCountAsync(simulatedDevice, messagesToSendEachLNS * 2, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); } [Fact] @@ -126,10 +126,10 @@ public async Task Single_OTAA_Simulated_Device() var device = new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: this.simulatedBasicsStations, logger: this.logger); Assert.True(await device.JoinAsync(), "OTAA join failed"); - await SendConfirmedUpstreamMessages(device, messageCount); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCount, this.uniqueMessageFragment); - await AssertIotHubMessageCountAsync(device, messageCount); - AssertMessageAcknowledgement(device, messageCount + 1); + await SimulationUtils.AssertIotHubMessageCountAsync(device, messageCount, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgement(device, messageCount + 1); } [Fact] @@ -140,7 +140,7 @@ public async Task Lots_Of_Devices_OTAA_Simulated_Load_Test() // arrange const int messageCounts = 10; - var simulatedDevices = InitializeSimulatedDevices(testDeviceInfo); + var simulatedDevices = SimulationUtils.InitializeSimulatedDevices(testDeviceInfo, this.simulatedBasicsStations, this.logger); Assert.NotEmpty(simulatedDevices); // act @@ -152,12 +152,12 @@ async Task ActAsync(SimulatedDevice device, TimeSpan startOffset) { await Task.Delay(startOffset); Assert.True(await device.JoinAsync(), "OTAA join failed"); - await SendConfirmedUpstreamMessages(device, messageCounts); + await SimulationUtils.SendConfirmedUpstreamMessages(device, messageCounts, this.uniqueMessageFragment); } // assert - await AssertIotHubMessageCountsAsync(simulatedDevices, messageCounts); - AssertMessageAcknowledgements(simulatedDevices, messageCounts + 1); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedDevices, messageCounts, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgements(simulatedDevices, messageCounts + 1); } /// @@ -191,7 +191,7 @@ public async Task Connected_Factory_Load_Test_Scenario() .Take(numberOfFactories) .Zip(testDeviceInfo.Chunk(testDeviceInfo.Count / numberOfFactories) .Take(numberOfFactories), - (ss, ds) => ds.Select(d => InitializeSimulatedDevice(d, ss)).ToList()); + (ss, ds) => ds.Select(d => SimulationUtils.InitializeSimulatedDevice(d, ss, this.logger)).ToList()); // Cache the devices in a flat list to make distributing requests easier. // Transposing the matrix makes sure that device requests are distributed evenly across factories, @@ -214,7 +214,7 @@ await ScheduleForEachAsync(devices, Intervals(TimeSpan.FromSeconds(1) / joinsPer await ScheduleForEachAsync(devices, Intervals(TimeSpan.FromSeconds(1) / messageRate), async d => { - using var request = CreateConfirmedUpstreamMessage(d); + using var request = SimulationUtils.CreateConfirmedUpstreamMessage(d, this.uniqueMessageFragment); await d.SendDataMessageAsync(request); }); } @@ -224,8 +224,8 @@ await ScheduleForEachAsync(devices, Intervals(TimeSpan.FromSeconds(1) / messageR // A correction needs to be applied since concentrators are distributed across LNS, even if they are in the same factory // (detailed description found at the beginning of this test). - await AssertIotHubMessageCountsAsync(devices, numberOfLoops, 1 / (double)numberOfFactories); - AssertMessageAcknowledgements(devices, numberOfLoops + 1); + await SimulationUtils.AssertIotHubMessageCountsAsync(devices, numberOfLoops, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count, 1 / (double)numberOfFactories); + SimulationUtils.AssertMessageAcknowledgements(devices, numberOfLoops + 1); static IEnumerable Intervals(TimeSpan step, TimeSpan? initial = null) => MoreLinq.MoreEnumerable.Generate(initial ?? TimeSpan.Zero, ts => ts + step); @@ -254,8 +254,8 @@ public async Task Multiple_ABP_and_OTAA_Simulated_Devices_Confirmed() const int messagesBeforeConfirmed = 5; var warmupDelay = TimeSpan.FromSeconds(5); - var simulatedAbpDevices = InitializeSimulatedDevices(testAbpDevicesInfo); - var simulatedOtaaDevices = InitializeSimulatedDevices(testOtaaDevicesInfo); + var simulatedAbpDevices = SimulationUtils.InitializeSimulatedDevices(testAbpDevicesInfo, this.simulatedBasicsStations, this.logger); + var simulatedOtaaDevices = SimulationUtils.InitializeSimulatedDevices(testOtaaDevicesInfo, this.simulatedBasicsStations, this.logger); Assert.Equal(simulatedAbpDevices.Count, simulatedOtaaDevices.Count); Assert.True(simulatedOtaaDevices.Count < 50, "Simulator does not work for more than 50 of each devices (due to IoT Edge connection mode). To go beyond 100 device clients, use edge hub environment variable 'MaxConnectedClients'."); Assert.True(messagesBeforeConfirmed <= messagesBeforeJoin, "OTAA devices should send all messages as confirmed messages."); @@ -316,99 +316,15 @@ static async Task JoinAsync(SimulatedDevice device) // 3. Check that the correct number of messages have arrived in IoT Hub per device // Warn only. - await AssertIotHubMessageCountsAsync(simulatedAbpDevices, messagesPerDeviceExcludingWarmup); - AssertMessageAcknowledgements(simulatedAbpDevices, messagesPerDeviceExcludingWarmup - messagesBeforeConfirmed); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedAbpDevices, messagesPerDeviceExcludingWarmup, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count); + SimulationUtils.AssertMessageAcknowledgements(simulatedAbpDevices, messagesPerDeviceExcludingWarmup - messagesBeforeConfirmed); // number of total data messages is number of messages per device minus the join message minus the number of messages sent before the join happens. const int numberOfOtaaDataMessages = messagesPerDeviceExcludingWarmup - messagesBeforeJoin - 1; - await AssertIotHubMessageCountsAsync(simulatedOtaaDevices, numberOfOtaaDataMessages, disableWaitForIotHub: true); - AssertMessageAcknowledgements(simulatedOtaaDevices, numberOfOtaaDataMessages + 1); + await SimulationUtils.AssertIotHubMessageCountsAsync(simulatedOtaaDevices, numberOfOtaaDataMessages, this.uniqueMessageFragment, this.logger, this.simulatedBasicsStations.Count, TestFixture.IoTHubMessages, Configuration.LnsEndpointsForSimulator.Count, disableWaitForIotHub: true); + SimulationUtils.AssertMessageAcknowledgements(simulatedOtaaDevices, numberOfOtaaDataMessages + 1); } - private static void AssertMessageAcknowledgement(SimulatedDevice device, int expectedCount) => - AssertMessageAcknowledgements(new[] { device }, expectedCount); - - private static void AssertMessageAcknowledgements(IEnumerable devices, int expectedCount) - { - if (expectedCount == 0) throw new ArgumentException(null, nameof(expectedCount)); - - foreach (var device in devices) - { - var minimumMessagesReceived = Math.Max((int)(expectedCount * (1 - DownstreamDroppedMessagesTolerance)), 1); - Assert.True(minimumMessagesReceived <= device.ReceivedMessages.Count, $"Too many downlink messages were dropped. Received {device.ReceivedMessages.Count} messages but expected at least {minimumMessagesReceived}."); - } - } - - private async Task SendConfirmedUpstreamMessages(SimulatedDevice device, int count) - { - for (var i = 0; i < count; ++i) - { - using var request = CreateConfirmedUpstreamMessage(device); - await device.SendDataMessageAsync(request); - await Task.Delay(IntervalBetweenMessages); - } - } - - private WaitableLoRaRequest CreateConfirmedUpstreamMessage(SimulatedDevice simulatedDevice) => - WaitableLoRaRequest.CreateWaitableRequest(simulatedDevice.CreateConfirmedDataUpMessage(this.uniqueMessageFragment + Guid.NewGuid())); - - private Task AssertIotHubMessageCountAsync(SimulatedDevice device, int numberOfMessages) => - AssertIotHubMessageCountsAsync(new[] { device }, numberOfMessages); - - private async Task AssertIotHubMessageCountsAsync(IEnumerable devices, - int numberOfMessages, - double? correction = null, - bool disableWaitForIotHub = false) - { - // Wait for messages in IoT Hub. - if (!disableWaitForIotHub) - { - await Task.Delay(TimeSpan.FromSeconds(100)); - } - - var actualMessageCounts = new Dictionary(); - foreach (var device in devices) - { - actualMessageCounts.Add(device.DevEUI, TestFixture.IoTHubMessages.Events.Count(e => ContainsMessageFromDevice(e, device))); - } - - bool ContainsMessageFromDevice(EventData eventData, SimulatedDevice simulatedDevice) - { - if (eventData.Properties.ContainsKey("iothub-message-schema")) return false; - if (eventData.GetDeviceId() != simulatedDevice.LoRaDevice.DeviceID) return false; - return Encoding.UTF8.GetString(eventData.EventBody).Contains(this.uniqueMessageFragment, StringComparison.Ordinal); - } - - this.logger.LogInformation("Message counts by DevEui:"); - this.logger.LogInformation(JsonSerializer.Serialize(actualMessageCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))); - - foreach (var device in devices) - { - var expectedMessageCount = device.LoRaDevice.Deduplication switch - { - DeduplicationMode.None or DeduplicationMode.Mark => numberOfMessages * this.simulatedBasicsStations.Count, - DeduplicationMode.Drop => numberOfMessages, - var mode => throw new SwitchExpressionException(mode) - }; - - if (!string.IsNullOrEmpty(device.LoRaDevice.GatewayID)) - { - expectedMessageCount /= Configuration.LnsEndpointsForSimulator.Count; - } - - var applicableMessageCount = correction is { } someCorrection ? expectedMessageCount * someCorrection : expectedMessageCount; - var actualMessageCount = actualMessageCounts[device.DevEUI]; - // Takes into account at-least-once delivery guarantees. - Assert.True(applicableMessageCount <= actualMessageCount, $"Expected at least {applicableMessageCount} IoT Hub messages for device {device.DevEUI} but counted {actualMessageCount}."); - } - } - - private List InitializeSimulatedDevices(IReadOnlyCollection testDeviceInfos) => - testDeviceInfos.Select(d => InitializeSimulatedDevice(d, this.simulatedBasicsStations)).ToList(); - - private SimulatedDevice InitializeSimulatedDevice(TestDeviceInfo testDeviceInfo, IReadOnlyCollection simulatedBasicsStations) => - new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: simulatedBasicsStations, logger: this.logger); - public async Task InitializeAsync() { await Task.WhenAll(from basicsStation in this.simulatedBasicsStations diff --git a/Tests/Simulation/SimulationUtils.cs b/Tests/Simulation/SimulationUtils.cs new file mode 100644 index 0000000000..35c79574be --- /dev/null +++ b/Tests/Simulation/SimulationUtils.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Simulation +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using Azure.Messaging.EventHubs; + using LoRaWan.NetworkServer; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Xunit; + + internal class SimulationUtils + { + private const double DownstreamDroppedMessagesTolerance = 0.02; + + internal static void AssertMessageAcknowledgement(SimulatedDevice device, int expectedCount) => + AssertMessageAcknowledgements(new[] { device }, expectedCount); + + internal static void AssertMessageAcknowledgements(IEnumerable devices, int expectedCount) + { + if (expectedCount == 0) throw new ArgumentException(null, nameof(expectedCount)); + + foreach (var device in devices) + { + var minimumMessagesReceived = Math.Max((int)(expectedCount * (1 - DownstreamDroppedMessagesTolerance)), 1); + Assert.True(minimumMessagesReceived <= device.ReceivedMessages.Count, $"Too many downlink messages were dropped. Received {device.ReceivedMessages.Count} messages but expected at least {minimumMessagesReceived}."); + } + } + + internal static async Task SendConfirmedUpstreamMessages(SimulatedDevice device, int count, string uniqueMessageFragment, int intervalBetweenMessagesInSeconds = 5) + { + for (var i = 0; i < count; ++i) + { + using var request = CreateConfirmedUpstreamMessage(device, uniqueMessageFragment); + await device.SendDataMessageAsync(request); + await Task.Delay(TimeSpan.FromSeconds(intervalBetweenMessagesInSeconds)); + } + } + + internal static WaitableLoRaRequest CreateConfirmedUpstreamMessage(SimulatedDevice simulatedDevice, string uniqueMessageFragment) => + WaitableLoRaRequest.CreateWaitableRequest(simulatedDevice.CreateConfirmedDataUpMessage(uniqueMessageFragment + Guid.NewGuid())); + + internal static Task AssertIotHubMessageCountAsync(SimulatedDevice device, + int numberOfMessages, + string uniqueMessageFragment, + TestOutputLogger logger, + int basicStationCount, + EventHubDataCollector hubDataCollector, + int lnsEndpointsForSimulatorCount) => + AssertIotHubMessageCountsAsync(new[] { device }, + numberOfMessages, + uniqueMessageFragment, + logger, + basicStationCount, + hubDataCollector, + lnsEndpointsForSimulatorCount); + + internal static async Task AssertIotHubMessageCountsAsync(IEnumerable devices, + int numberOfMessages, + string uniqueMessageFragment, + TestOutputLogger logger, + int basicStationCount, + EventHubDataCollector hubDataCollector, + int lnsEndpointsForSimulatorCount, + double? correction = null, + bool disableWaitForIotHub = false) + { + // Wait for messages in IoT Hub. + if (!disableWaitForIotHub) + { + await Task.Delay(TimeSpan.FromSeconds(100)); + } + + var actualMessageCounts = new Dictionary(); + foreach (var device in devices) + { + actualMessageCounts.Add(device.DevEUI, hubDataCollector.Events.Count(e => ContainsMessageFromDevice(e, device))); + } + + bool ContainsMessageFromDevice(EventData eventData, SimulatedDevice simulatedDevice) + { + if (eventData.Properties.ContainsKey("iothub-message-schema")) return false; + if (eventData.GetDeviceId() != simulatedDevice.LoRaDevice.DeviceID) return false; + return Encoding.UTF8.GetString(eventData.EventBody).Contains(uniqueMessageFragment, StringComparison.Ordinal); + } + + logger.Log(LogLevel.Information, "Message counts by DevEui:"); + logger.Log(LogLevel.Information, JsonSerializer.Serialize(actualMessageCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))); + + foreach (var device in devices) + { + var expectedMessageCount = device.LoRaDevice.Deduplication switch + { + DeduplicationMode.None or DeduplicationMode.Mark => numberOfMessages * basicStationCount, + DeduplicationMode.Drop => numberOfMessages, + var mode => throw new SwitchExpressionException(mode) + }; + + if (!string.IsNullOrEmpty(device.LoRaDevice.GatewayID)) + { + expectedMessageCount /= lnsEndpointsForSimulatorCount; + } + + var applicableMessageCount = correction is { } someCorrection ? expectedMessageCount * someCorrection : expectedMessageCount; + var actualMessageCount = actualMessageCounts[device.DevEUI]; + // Takes into account at-least-once delivery guarantees. + Assert.True(applicableMessageCount <= actualMessageCount, $"Expected at least {applicableMessageCount} IoT Hub messages for device {device.DevEUI} but counted {actualMessageCount}."); + } + } + + internal static List InitializeSimulatedDevices(IReadOnlyCollection testDeviceInfos, + IReadOnlyCollection simulatedBasicsStations, + TestOutputLogger logger) => + testDeviceInfos.Select(d => InitializeSimulatedDevice(d, simulatedBasicsStations, logger)).ToList(); + + internal static SimulatedDevice InitializeSimulatedDevice(TestDeviceInfo testDeviceInfo, + IReadOnlyCollection simulatedBasicsStations, + TestOutputLogger logger) => + new SimulatedDevice(testDeviceInfo, simulatedBasicsStation: simulatedBasicsStations, logger: logger); + } +} diff --git a/Tests/Simulation/appsettings.json b/Tests/Simulation/appsettings.json index e26afe40c6..1e4afd5391 100644 --- a/Tests/Simulation/appsettings.json +++ b/Tests/Simulation/appsettings.json @@ -6,11 +6,14 @@ "NetworkServerModuleLogAssertLevel": "Error", "IoTHubAssertLevel": "Warning", "TcpLog": true, + "TcpLogPort": "#{INTEGRATIONTEST_TcpLogPort}#", "CreateDevices": true, "LeafDeviceGatewayID": "#{INTEGRATIONTEST_LeafDeviceGatewayID}#", "DevicePrefix": "#{INTEGRATIONTEST_DevicePrefix}#", "LoadTestLnsEndpoints": "#{INTEGRATIONTEST_LoadTestLnsEndpoints}#", "NumberOfLoadTestDevices": "#{INTEGRATIONTEST_NumberOfLoadTestDevices}#", - "NumberOfLoadTestConcentrators": "#{INTEGRATIONTEST_NumberOfLoadTestConcentrators}#" + "NumberOfLoadTestConcentrators": "#{INTEGRATIONTEST_NumberOfLoadTestConcentrators}#", + "FunctionAppCode": "#{INTEGRATIONTEST_FunctionAppCode}#", + "FunctionAppBaseUrl": "#{INTEGRATIONTEST_FunctionAppBaseUrl}#" } } diff --git a/Tests/Unit/LoRaTools/ApiVersionTest.cs b/Tests/Unit/LoRaTools/ApiVersionTest.cs index ee4e1842cf..63064ae2f4 100644 --- a/Tests/Unit/LoRaTools/ApiVersionTest.cs +++ b/Tests/Unit/LoRaTools/ApiVersionTest.cs @@ -32,7 +32,8 @@ public async Task LatestVersion_Returns_Bad_Request_If_InvalidVersion_Requested( (req) => new DeviceGetter(null, null, NullLogger.Instance).GetDevice(req), (req) => Task.Run(() => new FCntCacheCheck(null, NullLogger.Instance).NextFCntDownInvoke(req)), (req) => Task.Run(() => new FunctionBundlerFunction(Array.Empty(), NullLogger.Instance).FunctionBundler(req, string.Empty)), - (req) => new SendCloudToDeviceMessage(null, null, null, null).Run(req, string.Empty) + (req) => new SendCloudToDeviceMessage(null, null, null, null, null, null).Run(req, string.Empty, default), + (req) => new ClearLnsCache(null, null, null, NullLogger.Instance).ClearNetworkServerCache(req, default) }; foreach (var apiCall in apiCalls) diff --git a/Tests/Unit/LoRaTools/LnsRemoteCallTests.cs b/Tests/Unit/LoRaTools/LnsRemoteCallTests.cs new file mode 100644 index 0000000000..a988b6c45e --- /dev/null +++ b/Tests/Unit/LoRaTools/LnsRemoteCallTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.LoRaTools +{ + using System.Text.Json; + using global::LoRaTools; + using Xunit; + + public sealed class LnsRemoteCallTests + { + [Fact] + public void Serialization_And_Deserialization_Preserves_Information() + { + // arrange + var subject = new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, "somepayload"); + + // act + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(subject)); + + // assert + Assert.Equal(subject, result); + } + } +} diff --git a/Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs b/Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs new file mode 100644 index 0000000000..c627cf1444 --- /dev/null +++ b/Tests/Unit/LoraKeysManagerFacade/ClearLnsCacheTest.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using global::LoraKeysManagerFacade; + using global::LoRaTools; + using Microsoft.Azure.Devices; + using Microsoft.Azure.Devices.Common.Exceptions; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using Xunit; + + public class ClearLnsCacheTest + { + private readonly Mock edgeDeviceGetter; + private readonly Mock serviceClient; + private readonly Mock channelPublisher; + private readonly ClearLnsCache clearLnsCache; + + public ClearLnsCacheTest() + { + this.edgeDeviceGetter = new Mock(); + this.serviceClient = new Mock(); + this.channelPublisher = new Mock(); + this.clearLnsCache = new ClearLnsCache(this.edgeDeviceGetter.Object, this.serviceClient.Object, this.channelPublisher.Object, NullLogger.Instance); + } + + [Fact] + public async Task ClearLnsCacheInternalAsync_Invokes_Both_Edge_And_Non_Edge_Devices() + { + //arrange + var listEdgeDevices = new List { "edge1", "edge2" }; + this.edgeDeviceGetter.Setup(m => m.ListEdgeDevicesAsync(It.IsAny())).ReturnsAsync(listEdgeDevices); + + this.serviceClient.Setup(m => m.InvokeDeviceMethodAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); + + //act + await this.clearLnsCache.ClearLnsCacheInternalAsync(default); + + //assert + foreach (var edgeDevice in listEdgeDevices) + { + this.serviceClient.Verify(c => c.InvokeDeviceMethodAsync(edgeDevice, + LoraKeysManagerFacadeConstants.NetworkServerModuleId, + It.Is(c => c.MethodName == LoraKeysManagerFacadeConstants.ClearCacheMethodName), + It.IsAny()), Times.Once()); + } + + this.channelPublisher.Verify(c => c.PublishAsync(LoraKeysManagerFacadeConstants.ClearCacheMethodName, It.Is(r => r.Kind == RemoteCallKind.ClearCache)), Times.Once()); + } + + [Fact] + public async Task ClearLnsCacheInternalAsync_Invokes_Only_Pub_Sub_When_No_Edge_Devices() + { + //arrange + this.edgeDeviceGetter.Setup(m => m.ListEdgeDevicesAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + + this.serviceClient.Setup(m => m.InvokeDeviceMethodAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); + + //act + await this.clearLnsCache.ClearLnsCacheInternalAsync(default); + + //assert + this.serviceClient.VerifyNoOtherCalls(); + + this.channelPublisher.Verify(c => c.PublishAsync(LoraKeysManagerFacadeConstants.ClearCacheMethodName, + It.Is(r => r.Kind == RemoteCallKind.ClearCache)), Times.Once()); + } + } +} diff --git a/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs b/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs new file mode 100644 index 0000000000..d937d1e43b --- /dev/null +++ b/Tests/Unit/LoraKeysManagerFacade/EdgeDeviceGetterTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using global::LoraKeysManagerFacade; + using global::LoRaTools; + using Microsoft.Azure.Devices; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using Xunit; + + public class EdgeDeviceGetterTests + { + private const string EdgeDevice1 = "edgeDevice1"; + private Mock mockRegistryManager; + private Mock mockQuery; + + public EdgeDeviceGetterTests() + { + InitRegistryManager(); + } + + [Theory] + [InlineData(EdgeDevice1, true)] + [InlineData("another", false)] + public async Task IsEdgeDeviceAsync_Returns_Proper_Answer(string lnsId, bool isEdge) + { + var edgeDeviceGetter = new EdgeDeviceGetter(InitRegistryManager(), new LoRaInMemoryDeviceStore(), NullLogger.Instance); + Assert.Equal(isEdge, await edgeDeviceGetter.IsEdgeDeviceAsync(lnsId, default)); + } + + [Fact] + public async Task IsEdgeDeviceAsync_Should_Not_Reach_IoTHub_Twice_If_Invoked_In_Less_Than_One_Minute() + { + var edgeDeviceGetter = new EdgeDeviceGetter(InitRegistryManager(), new LoRaInMemoryDeviceStore(), NullLogger.Instance); + Assert.True(await edgeDeviceGetter.IsEdgeDeviceAsync(EdgeDevice1, default)); + Assert.True(await edgeDeviceGetter.IsEdgeDeviceAsync(EdgeDevice1, default)); + Assert.False(await edgeDeviceGetter.IsEdgeDeviceAsync("anotherDevice", default)); + Assert.False(await edgeDeviceGetter.IsEdgeDeviceAsync("anotherDevice", default)); + + _ = this.mockQuery.Invocations.Single(x => x.Method.Name.Equals(nameof(IQuery.GetNextAsTwinAsync), System.StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ListEdgeDevicesAsync_Returns_Expected_Device_List() + { + var edgeDeviceGetter = new EdgeDeviceGetter(InitRegistryManager(), new LoRaInMemoryDeviceStore(), NullLogger.Instance); + + var list = await edgeDeviceGetter.ListEdgeDevicesAsync(default); + + Assert.Contains(EdgeDevice1, list); + } + + [Fact] + public async Task ListEdgeDevicesAsync_Returns_Empty_Device_List() + { + this.mockQuery = new Mock(); + this.mockRegistryManager = new Mock(); + + mockQuery.Setup(x => x.GetNextAsTwinAsync()) + .ReturnsAsync(Array.Empty()); + + mockRegistryManager + .Setup(x => x.CreateQuery(It.IsAny())) + .Returns(mockQuery.Object); + + var edgeDeviceGetter = new EdgeDeviceGetter(this.mockRegistryManager.Object, new LoRaInMemoryDeviceStore(), NullLogger.Instance); + + var list = await edgeDeviceGetter.ListEdgeDevicesAsync(default); + + Assert.Empty(list); + } + + private IDeviceRegistryManager InitRegistryManager() + { + this.mockQuery = new Mock(); + this.mockRegistryManager = new Mock(); + + var twins = new List() + { + new Twin(EdgeDevice1) { Capabilities = new DeviceCapabilities() { IotEdge = true }}, + }; + + mockQuery.Setup(x => x.GetNextAsTwinAsync()) + .ReturnsAsync(twins); + + mockRegistryManager + .Setup(x => x.CreateQuery(It.IsAny())) + .Returns(mockQuery.Object); + + return mockRegistryManager.Object; + } + } +} diff --git a/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs b/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs index 30258686d4..85985542a2 100644 --- a/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs +++ b/Tests/Unit/LoraKeysManagerFacade/FunctionBundlerTest.cs @@ -58,7 +58,11 @@ public FunctionBundlerTest() this.telemetryConfiguration = new TelemetryConfiguration(); var items = new IFunctionBundlerExecutionItem[] { - new DeduplicationExecutionItem(cacheStore, Mock.Of(), this.telemetryConfiguration), + new DeduplicationExecutionItem(cacheStore, + Mock.Of(), + Mock.Of(), + Mock.Of(), + this.telemetryConfiguration), this.adrExecutionItem, new NextFCntDownExecutionItem(new FCntCacheCheck(cacheStore, NullLogger.Instance)), new PreferredGatewayExecutionItem(cacheStore, new NullLogger(), null), @@ -339,7 +343,11 @@ public void Execution_Items_Should_Have_Correct_Priority() var items = new IFunctionBundlerExecutionItem[] { - new DeduplicationExecutionItem(cacheStore, Mock.Of(), this.telemetryConfiguration), + new DeduplicationExecutionItem(cacheStore, + Mock.Of(), + Mock.Of(), + Mock.Of(), + this.telemetryConfiguration), new ADRExecutionItem(this.adrManager), new NextFCntDownExecutionItem(new FCntCacheCheck(cacheStore, NullLogger.Instance)), new PreferredGatewayExecutionItem(cacheStore, new NullLogger(), null), diff --git a/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs b/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs index 42b726cb55..1251ff98c9 100644 --- a/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs +++ b/Tests/Unit/LoraKeysManagerFacade/MessageDeduplicationTests.cs @@ -4,6 +4,7 @@ namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade.FunctionBundler { using System; + using System.Threading; using System.Threading.Tasks; using global::LoraKeysManagerFacade; using global::LoraKeysManagerFacade.FunctionBundler; @@ -19,13 +20,21 @@ public sealed class MessageDeduplicationTests : FunctionTestBase, IDisposable private readonly DeduplicationExecutionItem deduplicationExecutionItem; private readonly Mock serviceClientMock; private readonly TelemetryConfiguration telemetryConfiguration; + private readonly Mock edgeDeviceGetter; public MessageDeduplicationTests() { this.serviceClientMock = new Mock(); this.telemetryConfiguration = new TelemetryConfiguration(); - this.deduplicationExecutionItem = new DeduplicationExecutionItem(new LoRaInMemoryDeviceStore(), this.serviceClientMock.Object, this.telemetryConfiguration); + this.edgeDeviceGetter = new Mock(); + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + this.deduplicationExecutionItem = new DeduplicationExecutionItem(new LoRaInMemoryDeviceStore(), + this.serviceClientMock.Object, + this.edgeDeviceGetter.Object, + Mock.Of(), + this.telemetryConfiguration); } [Fact] @@ -68,7 +77,7 @@ public async Task MessageDeduplication_DifferentDevices_Allowed() var dev2EUI = TestEui.GenerateDevEui(); this.serviceClientMock.Setup(x => x.InvokeDeviceMethodAsync( - It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny())) + It.IsAny(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsAny(), It.IsAny())) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = 200 }); var result = await this.deduplicationExecutionItem.GetDuplicateMessageResultAsync(dev1EUI, gateway1Id, 1, 1); @@ -85,7 +94,7 @@ public async Task MessageDeduplication_DifferentDevices_Allowed() x => x.InvokeDeviceMethodAsync(gateway1Id.ToString(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.Is( m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(dev1EUI.ToString()))), + && m.GetPayloadAsJson().Contains(dev1EUI.ToString())), It.IsAny()), Times.Once); result = await this.deduplicationExecutionItem.GetDuplicateMessageResultAsync(dev2EUI, gateway1Id, 1, 1); @@ -101,7 +110,7 @@ public async Task MessageDeduplication_DifferentDevices_Allowed() x => x.InvokeDeviceMethodAsync(gateway1Id.ToString(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.Is( m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection - && m.GetPayloadAsJson().Contains(dev2EUI.ToString()))), + && m.GetPayloadAsJson().Contains(dev2EUI.ToString())), It.IsAny()), Times.Once); } @@ -113,7 +122,7 @@ public async Task MessageDeduplication_When_Direct_Method_Throws_Does_Not_Throw( var dev1EUI = TestEui.GenerateDevEui(); this.serviceClientMock.Setup(x => x.InvokeDeviceMethodAsync( - It.IsAny(), It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new IotHubException("Failed to invoke direct method")); var result = await this.deduplicationExecutionItem.GetDuplicateMessageResultAsync(dev1EUI, gateway1Id, 1, 1); @@ -124,7 +133,7 @@ public async Task MessageDeduplication_When_Direct_Method_Throws_Does_Not_Throw( this.serviceClientMock.Verify( x => x.InvokeDeviceMethodAsync(gateway1Id.ToString(), LoraKeysManagerFacadeConstants.NetworkServerModuleId, - It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection)), + It.Is(m => m.MethodName == LoraKeysManagerFacadeConstants.CloudToDeviceCloseConnection), It.IsAny()), Times.Once); Assert.False(result.IsDuplicate); diff --git a/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs b/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs index 95f7af9cf5..23d6171fad 100644 --- a/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs +++ b/Tests/Unit/LoraKeysManagerFacade/SendCloudToDeviceMessageTest.cs @@ -8,6 +8,7 @@ namespace LoRaWan.Tests.Unit.LoraKeysManagerFacade using System.IO; using System.Net; using System.Text; + using System.Threading; using System.Threading.Tasks; using global::LoraKeysManagerFacade; using global::LoRaTools; @@ -30,6 +31,8 @@ public class SendCloudToDeviceMessageTest private readonly LoRaInMemoryDeviceStore cacheStore; private readonly Mock serviceClient; private readonly Mock registryManager; + private readonly Mock edgeDeviceGetter; + private readonly Mock channelPublisher; private readonly SendCloudToDeviceMessage sendCloudToDeviceMessage; public SendCloudToDeviceMessageTest() @@ -37,7 +40,14 @@ public SendCloudToDeviceMessageTest() this.cacheStore = new LoRaInMemoryDeviceStore(); this.serviceClient = new Mock(MockBehavior.Strict); this.registryManager = new Mock(MockBehavior.Strict); - this.sendCloudToDeviceMessage = new SendCloudToDeviceMessage(this.cacheStore, this.registryManager.Object, this.serviceClient.Object, new NullLogger()); + this.edgeDeviceGetter = new Mock(); + this.channelPublisher = new Mock(); + this.sendCloudToDeviceMessage = new SendCloudToDeviceMessage(this.cacheStore, + this.registryManager.Object, + this.serviceClient.Object, + this.edgeDeviceGetter.Object, + this.channelPublisher.Object, + new NullLogger()); } [Theory] @@ -53,7 +63,7 @@ public async Task When_DevEUI_Is_Missing_Should_Return_BadRequest(string devEUI) var request = new DefaultHttpContext().Request; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(c2dMessage))); - var result = await this.sendCloudToDeviceMessage.Run(request, devEUI); + var result = await this.sendCloudToDeviceMessage.Run(request, devEUI, default); Assert.IsType(result); @@ -64,7 +74,7 @@ public async Task When_DevEUI_Is_Missing_Should_Return_BadRequest(string devEUI) [Fact] public async Task When_Request_Is_Missing_Should_Return_BadRequest() { - var actual = await this.sendCloudToDeviceMessage.Run(null, new DevEui(123456789).ToString()); + var actual = await this.sendCloudToDeviceMessage.Run(null, new DevEui(123456789).ToString(), default); Assert.IsType(actual); @@ -77,7 +87,8 @@ public async Task When_Message_Is_Missing_Should_Return_BadRequest() { var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( new DevEui(123456789), - null); + null, + default); Assert.IsType(actual); @@ -98,7 +109,8 @@ public async Task When_Message_Is_Invalid_Should_Return_BadRequest() var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - c2dMessage); + c2dMessage, + default); Assert.IsType(actual); @@ -106,9 +118,13 @@ public async Task When_Message_Is_Invalid_Should_Return_BadRequest() this.registryManager.VerifyAll(); } - [Fact] - public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method_Or_Pub_Sub(bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); + var devEui = new DevEui(123456789); var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); @@ -120,13 +136,23 @@ public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method() }; LoRaCloudToDeviceMessage receivedC2DMessage = null; - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) - .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + + if (isEdgeDevice) + { + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) + .Callback((device, methodName, method, _) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + } + else + { + this.channelPublisher.Setup(x => x.PublishAsync("gateway1", It.IsNotNull())) + .Callback((device, remoteCall) => receivedC2DMessage = JsonConvert.DeserializeObject(remoteCall.JsonData)); + } var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, + default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -144,11 +170,13 @@ public async Task When_Device_Is_Found_In_Cache_Should_Send_Via_Direct_Method() [Fact] public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Error() { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var devEui = new DevEui(0123456789); var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.BadRequest }); var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( @@ -156,7 +184,7 @@ public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Er new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.BadRequest, ((ObjectResult)actual).StatusCode); @@ -168,11 +196,13 @@ public async Task When_Direct_Method_Returns_Error_Code_Should_Forward_Status_Er [Fact] public async Task When_Direct_Method_Throws_Exception_Should_Return_Application_Error() { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var devEui = new DevEui(123456789); var preferredGateway = new LoRaDevicePreferredGateway("gateway1", 100); LoRaDevicePreferredGateway.SaveToCache(this.cacheStore, devEui, preferredGateway); - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) .ThrowsAsync(new IotHubCommunicationException(string.Empty)); var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( @@ -180,7 +210,7 @@ public async Task When_Direct_Method_Throws_Exception_Should_Return_Application_ new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.InternalServerError, ((ObjectResult)actual).StatusCode); @@ -207,7 +237,7 @@ public async Task When_Device_Does_Not_Have_DevAddr_Should_Return_BadRequest() new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); @@ -234,7 +264,7 @@ public async Task When_Querying_Devices_Throws_Exception_Should_Return_Applicati new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.InternalServerError, ((ObjectResult)actual).StatusCode); @@ -262,7 +292,7 @@ public async Task When_Querying_Devices_Is_Empty_Should_Return_NotFound() new LoRaCloudToDeviceMessage() { Fport = TestPort, - }); + }, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.NotFound, ((ObjectResult)actual).StatusCode); @@ -272,9 +302,13 @@ public async Task When_Querying_Devices_Is_Empty_Should_Return_NotFound() query.VerifyAll(); } - [Fact] - public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_And_Send_Direct_Method() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_And_Send_Direct_Method_Or_Pub_Sub(bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); + var devEui = new DevEui(123456789); var deviceTwin = new Twin @@ -301,13 +335,23 @@ public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_An }; LoRaCloudToDeviceMessage receivedC2DMessage = null; - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) - .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + + if (isEdgeDevice) + { + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("gateway1", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) + .Callback((device, methodName, method, _) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + } + else + { + this.channelPublisher.Setup(x => x.PublishAsync("gateway1", It.IsNotNull())) + .Callback((device, remoteCall) => receivedC2DMessage = JsonConvert.DeserializeObject(remoteCall.JsonData)); + } + var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -327,9 +371,13 @@ public async Task When_Querying_Devices_And_Finds_Class_C_Should_Update_Cache_An query.VerifyAll(); } - [Fact] - public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_Update_Cache_And_Send_Direct_Method() + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_Update_Cache_And_Send_Direct_Method_Or_Pub_Sub(bool isEdgeDevice) { + this.edgeDeviceGetter.Setup(m => m.IsEdgeDeviceAsync(It.IsAny(), It.IsAny())).ReturnsAsync(isEdgeDevice); var devEui = new DevEui(123456789); var deviceTwin = new Twin @@ -356,13 +404,21 @@ public async Task When_Querying_Devices_And_Finds_Single_Gateway_Class_C_Should_ }; LoRaCloudToDeviceMessage receivedC2DMessage = null; - this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("mygateway", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull())) - .Callback((device, methodName, method) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) - .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + if (isEdgeDevice) + { + this.serviceClient.Setup(x => x.InvokeDeviceMethodAsync("mygateway", LoraKeysManagerFacadeConstants.NetworkServerModuleId, It.IsNotNull(), default)) + .Callback((device, methodName, method, _) => receivedC2DMessage = JsonConvert.DeserializeObject(method.GetPayloadAsJson())) + .ReturnsAsync(new CloudToDeviceMethodResult() { Status = (int)HttpStatusCode.OK }); + } + else + { + this.channelPublisher.Setup(x => x.PublishAsync("mygateway", It.IsNotNull())) + .Callback((device, remoteCall) => receivedC2DMessage = JsonConvert.DeserializeObject(remoteCall.JsonData)); + } var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -413,7 +469,7 @@ public async Task When_Querying_Devices_And_Finds_No_Gateway_For_Class_C_Should_ var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); var result = Assert.IsType(actual); Assert.Equal(500, result.StatusCode); @@ -460,7 +516,7 @@ public async Task When_Querying_Devices_And_Finds_Class_A_Should_Send_Message() var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); var responseValue = ((OkObjectResult)actual).Value as SendCloudToDeviceMessageResult; @@ -512,7 +568,7 @@ public async Task When_Sending_Message_Throws_Error_Should_Return_Application_Er var actual = await this.sendCloudToDeviceMessage.SendCloudToDeviceMessageImplementationAsync( devEui, - actualMessage); + actualMessage, default); Assert.IsType(actual); Assert.Equal((int)HttpStatusCode.InternalServerError, ((ObjectResult)actual).StatusCode); diff --git a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs index 76ecb2383e..e940590c78 100644 --- a/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs +++ b/Tests/Unit/NetworkServer/BasicsStation/BasicsStationNetworkServerStartupTests.cs @@ -3,7 +3,9 @@ namespace LoRaWan.Tests.Unit.NetworkServer.BasicsStation { + using System; using LoRaWan.NetworkServer.BasicsStation; + using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -16,16 +18,83 @@ public void All_Dependencies_Are_Registered_Correctly() // arrange var services = new ServiceCollection(); var config = new ConfigurationBuilder().Build(); + var envVariables = new[] + { + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") + }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + // act + assert + var startup = new BasicsStationNetworkServerStartup(config); + startup.ConfigureServices(services); + + services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + + } + finally + { + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ModuleConnectionHostIsInjectedOrNot(bool cloud_deployment, bool enable_gateway) + { + var envVariables = new[] + { + ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), + ("ENABLE_GATEWAY", enable_gateway.ToString()), + ("REDIS_CONNECTION_STRING", "someString"), + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") + }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + var services = new ServiceCollection(); + var config = new ConfigurationBuilder().Build(); + + // act + assert + var startup = new BasicsStationNetworkServerStartup(config); + startup.ConfigureServices(services); + + var serviceProvider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); - // act + assert - var startup = new BasicsStationNetworkServerStartup(config); - startup.ConfigureServices(services); + var result = serviceProvider.GetService(); + if (cloud_deployment) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + } - services.BuildServiceProvider(new ServiceProviderOptions + } + finally { - ValidateOnBuild = true, - ValidateScopes = true - }); + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } } } } diff --git a/Tests/Unit/NetworkServer/CloudControlHostTests.cs b/Tests/Unit/NetworkServer/CloudControlHostTests.cs new file mode 100644 index 0000000000..0ef185c8ef --- /dev/null +++ b/Tests/Unit/NetworkServer/CloudControlHostTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +namespace LoRaWan.Tests.Unit.NetworkServer +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using global::LoRaTools; + using LoRaWan.NetworkServer; + using Moq; + using Xunit; + + public sealed class CloudControlHostTests + { + private const string GatewayId = "lns-1"; + private readonly Mock lnsRemoteCallHandler; + private readonly Mock lnsRemoteCallListener; + private readonly CloudControlHost subject; + + public CloudControlHostTests() + { + this.lnsRemoteCallHandler = new Mock(); + this.lnsRemoteCallListener = new Mock(); + this.subject = new CloudControlHost(this.lnsRemoteCallListener.Object, this.lnsRemoteCallHandler.Object, new NetworkServerConfiguration { GatewayID = GatewayId }); + } + + [Fact] + public async Task ExecuteAsync_Subscribes_To_LnsRemoteCallHandler() + { + // arrange + Func actualHandler = _ => Task.CompletedTask; + this.lnsRemoteCallListener + .Setup(l => l.SubscribeAsync(GatewayId, It.IsAny>(), It.IsAny())) + .Callback((string _, Func handler, CancellationToken _) => actualHandler = handler); + + // act + await this.subject.StartAsync(CancellationToken.None); + + // assert + this.lnsRemoteCallListener.Verify(l => l.SubscribeAsync(GatewayId, It.IsAny>(), It.IsAny())); + await actualHandler.Invoke(new LnsRemoteCall(RemoteCallKind.CloseConnection, null)); + this.lnsRemoteCallHandler.Verify(l => l.ExecuteAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StopAsync_Unsubscribes() + { + // act + await this.subject.StopAsync(CancellationToken.None); + + // assert + this.lnsRemoteCallListener.Verify(l => l.UnsubscribeAsync(GatewayId, It.IsAny()), Times.Once); + } + } +} diff --git a/Tests/Unit/NetworkServer/ConfigurationTest.cs b/Tests/Unit/NetworkServer/ConfigurationTest.cs index e1607fb7e0..93e2beaf98 100644 --- a/Tests/Unit/NetworkServer/ConfigurationTest.cs +++ b/Tests/Unit/NetworkServer/ConfigurationTest.cs @@ -14,7 +14,13 @@ public class ConfigurationTest [MemberData(nameof(AllowedDevAddressesInput))] public void Should_Setup_Allowed_Dev_Addresses_Correctly(string inputAllowedDevAddrValues, DevAddr[] expectedAllowedDevAddrValues) { - var envVariables = new[] { ("AllowedDevAddresses", inputAllowedDevAddrValues), ("FACADE_SERVER_URL", "https://aka.ms") }; + var envVariables = new[] + { + ("AllowedDevAddresses", inputAllowedDevAddrValues), + ("FACADE_SERVER_URL", "https://aka.ms"), + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") + }; try { @@ -35,9 +41,117 @@ public void Should_Setup_Allowed_Dev_Addresses_Correctly(string inputAllowedDevA } } + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void Should_Throw_On_Invalid_Cloud_Configuration_When_Redis_Connection_String_Not_Set(bool shouldSetRedisString, bool isCloudDeployment) + { + // arrange + var cloudDeploymentKey = "CLOUD_DEPLOYMENT"; + var key = "REDIS_CONNECTION_STRING"; + var value = "someValue"; + var lnsConfigurationCreation = () => NetworkServerConfiguration.CreateFromEnvironmentVariables(); + + Environment.SetEnvironmentVariable("HOSTNAME", "test"); + Environment.SetEnvironmentVariable("IOTHUBHOSTNAME", "test"); + + if (isCloudDeployment) + { + Environment.SetEnvironmentVariable(cloudDeploymentKey, true.ToString()); + Environment.SetEnvironmentVariable("ENABLE_GATEWAY", false.ToString()); + } + + if (shouldSetRedisString) + Environment.SetEnvironmentVariable(key, value); + + // act and assert + if (isCloudDeployment && !shouldSetRedisString) + { + _ = Assert.Throws(lnsConfigurationCreation); + } + else + { + _ = lnsConfigurationCreation(); + } + + Environment.SetEnvironmentVariable(key, string.Empty); + Environment.SetEnvironmentVariable(cloudDeploymentKey, string.Empty); + Environment.SetEnvironmentVariable("ENABLE_GATEWAY", string.Empty); + } + + public static TheoryData AllowedDevAddressesInput => TheoryDataFactory.From(("0228B1B1;", new[] { new DevAddr(0x0228b1b1) }), ("0228B1B1;0228B1B2", new DevAddr[] { new DevAddr(0x0228b1b1), new DevAddr(0x0228b1b2) }), ("ads;0228B1B2;", new DevAddr[] { new DevAddr(0x0228b1b2) })); + + [Theory] + [CombinatorialData] + public void EnableGatewayTrue_IoTModuleFalse_IsNotSupported(bool cloud_deployment, bool enable_gateway) + { + var envVariables = new[] + { + ("CLOUD_DEPLOYMENT", cloud_deployment.ToString()), + ("ENABLE_GATEWAY", enable_gateway.ToString()), + ("REDIS_CONNECTION_STRING", "someString"), + ("HOSTNAME", "test"), + ("IOTHUBHOSTNAME", "test") + }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + if (cloud_deployment && enable_gateway) + { + Assert.Throws(() => { + _ = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + }); + } + else + { + _ = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + } + } + finally + { + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } + } + + [Theory] + [InlineData("500")] + [InlineData("x")] + public void ProcessingDelayIsConfigurable(string processing_delay) + { + var envVariables = new[] + { + ("PROCESSING_DELAY_IN_MS", processing_delay), + ("HOSTNAME", "test") + }; + + try + { + foreach (var (key, value) in envVariables) + Environment.SetEnvironmentVariable(key, value); + + var networkServerConfiguration = NetworkServerConfiguration.CreateFromEnvironmentVariables(); + + if (!int.TryParse(processing_delay, out var int_processing_delay)) + { + int_processing_delay = Constants.DefaultProcessingDelayInMilliseconds; + } + Assert.Equal(int_processing_delay, networkServerConfiguration.ProcessingDelayInMilliseconds); + } + finally + { + foreach (var (key, _) in envVariables) + Environment.SetEnvironmentVariable(key, string.Empty); + } + } } } diff --git a/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs b/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs new file mode 100644 index 0000000000..95485281c7 --- /dev/null +++ b/Tests/Unit/NetworkServer/LnsRemoteCallHandlerTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace LoRaWan.Tests.Unit.NetworkServer +{ + using System; + using System.Net; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Bogus; + using global::LoRaTools; + using LoRaWan.NetworkServer; + using LoRaWan.Tests.Common; + using Microsoft.Extensions.Logging; + using Moq; + using Xunit; + + public sealed class LnsRemoteCallHandlerTests + { + private readonly Faker faker = new(); + private readonly NetworkServerConfiguration networkServerConfiguration; + private readonly Mock classCMessageSender; + private readonly Mock loRaDeviceRegistry; + private readonly Mock> logger; + private readonly LnsRemoteCallHandler subject; + + public LnsRemoteCallHandlerTests() + { + this.networkServerConfiguration = new NetworkServerConfiguration(); + this.classCMessageSender = new Mock(); + this.loRaDeviceRegistry = new Mock(); + this.logger = new Mock>(); + this.subject = new LnsRemoteCallHandler(this.networkServerConfiguration, + this.classCMessageSender.Object, + this.loRaDeviceRegistry.Object, + this.logger.Object, + TestMeter.Instance); + } + + + [Fact] + public async Task CloseConnectionAsync_Should_Work_As_Expected() + { + // arrange + var devEui = new DevEui(0); + var mockedDevice = new Mock(null, devEui, null); + _ = this.loRaDeviceRegistry.Setup(x => x.GetDeviceByDevEUIAsync(devEui)).ReturnsAsync(mockedDevice.Object); + var c2d = JsonSerializer.Serialize(new + { + DevEui = devEui.ToString(), + Fport = 1, + MessageId = Guid.NewGuid(), + }); + + // act + _ = await CloseConnectionAsync(c2d); + + // assert + this.loRaDeviceRegistry.VerifyAll(); + mockedDevice.Verify(x => x.CloseConnectionAsync(It.IsAny(), true), Times.Once); + } + + [Fact] + public async Task ClearCache_When_Correct_Should_Work() + { + // arrange + this.loRaDeviceRegistry.Setup(x => x.ResetDeviceCacheAsync()).Returns(Task.CompletedTask); + this.networkServerConfiguration.IoTEdgeTimeout = 5; + + // act + await this.subject.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.ClearCache, null), CancellationToken.None); + + // assert + this.loRaDeviceRegistry.VerifyAll(); + } + + public static TheoryData DropConnectionInvalidMessages => + TheoryDataFactory.From( + (string.Empty, "Unable to parse Json when attempting to close"), + ("null", "Missing payload when attempting to close the"), + (JsonSerializer.Serialize(new { DevEui = (string)null, Fport = 1 }), "DevEUI missing"), + (JsonSerializer.Serialize(new { DevEui = new DevEui(0).ToString(), Fport = 1, MessageId = 123 }), "Unable to parse Json")); + + [Theory] + [MemberData(nameof(DropConnectionInvalidMessages))] + public async Task CloseConnectionAsync_Should_Return_Bad_Request_When_Invalid_Message(string json, string expectedLogPattern) + { + // act + var response = await CloseConnectionAsync(json); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, response); + var log = Assert.Single(this.logger.GetLogInvocations()); + Assert.Matches(expectedLogPattern, log.Message); + this.loRaDeviceRegistry.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CloseConnectionAsync_Should_Return_NotFound_When_Device_Not_Found() + { + // arrange + var devEui = new DevEui(0); + var c2d = JsonSerializer.Serialize(new { DevEui = devEui.ToString(), Fport = 1 }); + + // act + var response = await CloseConnectionAsync(c2d); + + // assert + Assert.Equal(HttpStatusCode.NotFound, response); + this.loRaDeviceRegistry.Verify(x => x.GetDeviceByDevEUIAsync(devEui), Times.Once); + this.loRaDeviceRegistry.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SendCloudToDeviceMessageAsync_When_Correct_Should_Work() + { + // arrange + this.classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var c2d = "{\"test\":\"asd\"}"; + + // act + var response = await SendCloudToDeviceMessageAsync(c2d); + + // assert + Assert.Equal(HttpStatusCode.OK, response); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Null_Or_Empty_Should_Return_Not_Found(string json) + { + this.classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var response = await SendCloudToDeviceMessageAsync(json); + Assert.Equal(HttpStatusCode.BadRequest, response); + } + + [Fact] + public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Not_CorrectJson_Should_Return_Not_Found() + { + var response = await SendCloudToDeviceMessageAsync(this.faker.Random.String2(10)); + Assert.Equal(HttpStatusCode.BadRequest, response); + } + + private Task CloseConnectionAsync(string payload) => + this.subject.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloseConnection, payload), CancellationToken.None); + + private Task SendCloudToDeviceMessageAsync(string payload) => + this.subject.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, payload), CancellationToken.None); + } +} diff --git a/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs b/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs index 95879c8219..b5b4b7cd5d 100644 --- a/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs +++ b/Tests/Unit/NetworkServer/LoRaDeviceCacheTest.cs @@ -17,7 +17,6 @@ namespace LoRaWan.Tests.Unit.NetworkServer public class LoRaDeviceCacheTest { - [Fact] public async Task When_Device_Expires_It_Is_Refreshed() { @@ -57,7 +56,7 @@ public async Task When_Device_Is_Fresh_No_Refresh_Is_Triggered() device.LastUpdate = DateTime.UtcNow + TimeSpan.FromMinutes(1); cache.Register(device); - using var cts = new CancellationTokenSource(this.quickRefreshOptions.ValidationInterval * 2); + using var cts = this.quickRefreshOptions.ValidationIntervalCancellationToken(); await Assert.ThrowsAsync(() => cache.WaitForRefreshAsync(cts.Token)); } @@ -83,7 +82,7 @@ public async Task When_Disposed_While_Refreshing_We_Shutdown_Gracefully() await cache.DisposeAsync(); var count = cache.DeviceRefreshCount; - await Task.Delay(this.quickRefreshOptions.ValidationInterval * 2); + await Task.Delay(this.quickRefreshOptions.ValidationIntervalDelay()); Assert.Equal(count, cache.DeviceRefreshCount); } @@ -118,7 +117,7 @@ public async Task When_Device_Inactive_It_Is_Removed() await using var device = new LoRaDevice(new DevAddr(0xabc), new DevEui(0x123), connectionManager.Object) { LastSeen = DateTime.UtcNow }; cache.Register(device); - using var cts = new CancellationTokenSource(this.quickRefreshOptions.ValidationInterval * 2); + using var cts = this.quickRefreshOptions.ValidationIntervalCancellationToken(); await cache.WaitForRemoveAsync(cts.Token); Assert.False(cache.TryGetByDevEui(device.DevEUI, out _)); @@ -450,11 +449,9 @@ protected override void OnRefresh() public override async Task RemoveAsync(LoRaDevice device) { + var ret = await base.RemoveAsync(device); if (this.removeTick.CurrentCount == 0) this.removeTick.Release(); - - var ret = await base.RemoveAsync(device); - return ret; } @@ -474,4 +471,11 @@ protected override ValueTask DisposeAsync(bool dispose) } } } + internal static class LoRaDeviceCacheOptionsExtensions + { + public static TimeSpan ValidationIntervalDelay(this LoRaDeviceCacheOptions options) + => options.ValidationInterval * 3; + + public static CancellationTokenSource ValidationIntervalCancellationToken(this LoRaDeviceCacheOptions options) => new CancellationTokenSource(options.ValidationIntervalDelay()); + } } diff --git a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs index ca09eb22f5..a04330260a 100644 --- a/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs +++ b/Tests/Unit/NetworkServer/ModuleConnectionHostTest.cs @@ -4,18 +4,17 @@ namespace LoRaWan.Tests.Unit.NetworkServer { using Bogus; + using global::LoRaTools; using LoRaWan.NetworkServer; using LoRaWan.NetworkServer.BasicsStation.ModuleConnection; using LoRaWan.Tests.Common; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Client.Exceptions; using Microsoft.Azure.Devices.Shared; - using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using System; using System.Configuration; - using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; @@ -23,51 +22,48 @@ namespace LoRaWan.Tests.Unit.NetworkServer using System.Threading.Tasks; using Xunit; - public class ModuleConnectionHostTest + public sealed class ModuleConnectionHostTest : IAsyncDisposable { - + private readonly NetworkServerConfiguration networkServerConfiguration; private readonly Mock loRaModuleClientFactory = new(); private readonly Mock loRaModuleClient = new(); private readonly LoRaDeviceAPIServiceBase loRaDeviceApiServiceBase = Mock.Of(); private readonly Faker faker = new Faker(); + private readonly Mock lnsRemoteCall; + private readonly ModuleConnectionHost subject; public ModuleConnectionHostTest() { + this.networkServerConfiguration = new NetworkServerConfiguration(); this.loRaModuleClient.Setup(x => x.DisposeAsync()); this.loRaModuleClientFactory.Setup(x => x.CreateAsync()).ReturnsAsync(loRaModuleClient.Object); + this.lnsRemoteCall = new Mock(); + this.subject = new ModuleConnectionHost(this.networkServerConfiguration, + this.loRaModuleClientFactory.Object, + this.loRaDeviceApiServiceBase, + this.lnsRemoteCall.Object, + NullLogger.Instance, + TestMeter.Instance); } [Fact] public void When_Constructor_Receives_Null_Parameters_Should_Throw() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - // ASSERT ArgumentNullException ex; - ex = Assert.Throws(() => new ModuleConnectionHost(null, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); + ex = Assert.Throws(() => new ModuleConnectionHost(null, this.loRaModuleClientFactory.Object, this.loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("networkServerConfiguration", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, null, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); - Assert.Equal("defaultClassCDevicesMessageSender", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, null, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); + ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, null, this.loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("loRaModuleClientFactory", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, null, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance)); - Assert.Equal("loRaDeviceRegistry", ex.ParamName); - ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, null, NullLogger.Instance, TestMeter.Instance)); + ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, null, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance)); Assert.Equal("loRaDeviceAPIService", ex.ParamName); + ex = Assert.Throws(() => new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, this.loRaDeviceApiServiceBase, null, NullLogger.Instance, TestMeter.Instance)); + Assert.Equal("lnsRemoteCallHandler", ex.ParamName); } [Fact] public async Task On_Desired_Properties_Correct_Update_Should_Update_Api_Service_Configuration() { - var networkServerConfiguration = Mock.Of(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); var url1 = this.faker.Internet.Url(); var authCode = this.faker.Internet.Password(); @@ -77,7 +73,7 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Api_Service FacadeAuthCode = authCode, }); - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(input), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(input), null); Assert.Equal(url1 + "/", loRaDeviceApiServiceBase.URL.ToString()); var url2 = this.faker.Internet.Url(); var input2 = JsonSerializer.Serialize(new @@ -85,7 +81,7 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Api_Service FacadeServerUrl = url2, FacadeAuthCode = authCode, }); - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(input2), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(input2), null); Assert.Equal(url2 + "/", loRaDeviceApiServiceBase.URL.ToString()); Assert.Equal(authCode, loRaDeviceApiServiceBase.AuthCode.ToString()); } @@ -105,11 +101,7 @@ public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Api_S }; var localLoRaDeviceApiServiceBase = new LoRaDeviceAPIService(networkServerConfiguration, Mock.Of(), NullLogger.Instance, TestMeter.Instance); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClientFactory = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, localLoRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); + await using var moduleClientFactory = new ModuleConnectionHost(networkServerConfiguration, this.loRaModuleClientFactory.Object, localLoRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await moduleClientFactory.OnDesiredPropertiesUpdate(new TwinCollection(twinUpdate), null); Assert.Equal(facadeUri + "/", localLoRaDeviceApiServiceBase.URL.ToString()); @@ -123,13 +115,6 @@ public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Api_S [InlineData(1000)] public async Task On_Desired_Properties_Correct_Update_Should_Update_Processing_Delay(int processingDelay) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - Assert.Equal(Constants.DefaultProcessingDelayInMilliseconds, networkServerConfiguration.ProcessingDelayInMilliseconds); var input = JsonSerializer.Serialize(new @@ -137,7 +122,7 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Processing_ ProcessingDelayInMilliseconds = processingDelay, }); - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(input), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(input), null); Assert.Equal(processingDelay, networkServerConfiguration.ProcessingDelayInMilliseconds); } @@ -147,26 +132,19 @@ public async Task On_Desired_Properties_Correct_Update_Should_Update_Processing_ [InlineData("{ ProcessingDelay: 200 }")] public async Task On_Desired_Properties_Incorrect_Update_Should_Not_Update_Processing_Delay(string twinUpdate) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = Mock.Of(); - var loRaDeviceRegistry = Mock.Of(); - var loRaModuleClientFactory = Mock.Of(); - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender, loRaModuleClientFactory, loRaDeviceRegistry, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - await moduleClient.OnDesiredPropertiesUpdate(new TwinCollection(twinUpdate), null); + await this.subject.OnDesiredPropertiesUpdate(new TwinCollection(twinUpdate), null); Assert.Equal(Constants.DefaultProcessingDelayInMilliseconds, networkServerConfiguration.ProcessingDelayInMilliseconds); } [Fact] public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); + var timeotNetworkServerConfiguration = new NetworkServerConfiguration() + { + // Change the iot edge timeout. + IoTEdgeTimeout = 5 + }; - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; var facadeUri = this.faker.Internet.Url(); var facadeCode = this.faker.Internet.Password(); var processingDelay = 1000; @@ -183,11 +161,11 @@ public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); + await using var moduleClient = new ModuleConnectionHost(timeotNetworkServerConfiguration, this.loRaModuleClientFactory.Object, loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await moduleClient.CreateAsync(CancellationToken.None); Assert.Equal(facadeUri + "/", loRaDeviceApiServiceBase.URL.ToString()); Assert.Equal(facadeCode, loRaDeviceApiServiceBase.AuthCode); - Assert.Equal(processingDelay, networkServerConfiguration.ProcessingDelayInMilliseconds); + Assert.Equal(processingDelay, timeotNetworkServerConfiguration.ProcessingDelayInMilliseconds); } [Theory] @@ -195,9 +173,6 @@ public async Task InitModuleAsync_Update_Should_Perform_Happy_Path() [InlineData("{ FacadeAuthCode: 'asdasdada' }")] public async Task InitModuleAsync_Fails_When_Required_Twins_Are_Not_Set(string twin) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); var loRaModuleClient = new Mock(); loRaModuleClient.Setup(x => x.DisposeAsync()); var loRaModuleClientFactory = new Mock(); @@ -210,7 +185,7 @@ public async Task InitModuleAsync_Fails_When_Required_Twins_Are_Not_Set(string t }; loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); + await using var moduleClient = new ModuleConnectionHost(this.networkServerConfiguration, this.loRaModuleClientFactory.Object, loRaDeviceApiServiceBase, this.lnsRemoteCall.Object, NullLogger.Instance, TestMeter.Instance); await Assert.ThrowsAsync(() => moduleClient.CreateAsync(CancellationToken.None)); } @@ -221,11 +196,6 @@ public async Task InitModuleAsync_Fails_When_Required_Twins_Are_Not_Set(string t [InlineData("invalidDelay")] public async Task InitModuleAsync_Does_Not_Fail_When_Processing_Delay_Missing_Or_Incorrect(string processingDelay) { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - //networkServerConfiguration.IoTEdgeTimeout = 5; var facadeUri = this.faker.Internet.Url(); var facadeCode = this.faker.Internet.Password(); var twinProperty = new TwinProperties @@ -239,207 +209,65 @@ public async Task InitModuleAsync_Does_Not_Fail_When_Processing_Delay_Missing_Or })) }; - loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); + this.loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).ReturnsAsync(new Twin(twinProperty)); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - await moduleClient.CreateAsync(CancellationToken.None); + await this.subject.CreateAsync(CancellationToken.None); Assert.Equal(Constants.DefaultProcessingDelayInMilliseconds, networkServerConfiguration.ProcessingDelayInMilliseconds); } [Fact] public async Task InitModuleAsync_Fails_When_Fail_IoT_Hub_Communication() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; + this.networkServerConfiguration.IoTEdgeTimeout = 5; - loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).Throws(); + this.loRaModuleClient.Setup(x => x.GetTwinAsync(It.IsAny())).Throws(); - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - var ex = await Assert.ThrowsAsync(() => moduleClient.CreateAsync(CancellationToken.None)); + var ex = await Assert.ThrowsAsync(() => this.subject.CreateAsync(CancellationToken.None)); Assert.Equal(LoRaProcessingErrorCode.TwinFetchFailed, ex.ErrorCode); } [Fact] - public async Task OnDirectMethodCall_ClearCache_When_Correct_Should_Work() + public async Task OnDirectMethodCall_Should_Invoke_ClearCache() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - loRaDeviceRegistry.Setup(x => x.ResetDeviceCacheAsync()).Returns(Task.CompletedTask); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceClearCache), null); - loRaDeviceRegistry.VerifyAll(); + await this.subject.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceClearCache), null); + this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.ClearCache, null), CancellationToken.None), Times.Once); } [Fact] - public async Task OnDirectMethodCall_DropConnection_Should_Work_As_Expected() + public async Task OnDirectMethodCall_Should_Invoke_DropConnection() { // arrange - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - var devEui = new DevEui(0); - var mockedDevice = new Mock(null, devEui, null); - _ = loRaDeviceRegistry.Setup(x => x.GetDeviceByDevEUIAsync(devEui)).ReturnsAsync(mockedDevice.Object); - var c2d = JsonSerializer.Serialize(new - { - DevEui = devEui.ToString(), - Fport = 1, - MessageId = Guid.NewGuid(), - }); + var json = @"{""foo"":""bar""}"; + var methodRequest = new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(json)); // act - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(c2d)), null); + await this.subject.OnDirectMethodCalled(methodRequest, null); // assert - loRaDeviceRegistry.VerifyAll(); - mockedDevice.Verify(x => x.CloseConnectionAsync(It.IsAny(), true), Times.Once); - } - - public static TheoryData DropConnectionInvalidMessages => - TheoryDataFactory.From( - (string.Empty, "Missing payload"), - ("null", "Missing payload"), - (JsonSerializer.Serialize(new { DevEui = (string)null, Fport = 1 }), "DevEUI missing"), - (JsonSerializer.Serialize(new { DevEui = new DevEui(0).ToString(), Fport = 1, MessageId = 123 }), "Unable to parse Json")); - - [Theory] - [MemberData(nameof(DropConnectionInvalidMessages))] - public async Task OnDirectMethodCall_DropConnection_Should_Return_Bad_Request_When_Invalid_Message(string json, string expectedLogPattern) - { - // arrange - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - var loggerMock = new Mock>(); - - // act - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, loggerMock.Object, TestMeter.Instance); - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(json)), null); - - // assert - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - var log = Assert.Single(loggerMock.GetLogInvocations()); - Assert.Matches(expectedLogPattern, log.Message); - loRaDeviceRegistry.VerifyNoOtherCalls(); + this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloseConnection, json), CancellationToken.None), Times.Once); } [Fact] - public async Task OnDirectMethodCall_DropConnection_Should_Return_NotFound_When_Device_Not_Found() + public async Task OnDirectMethodCall_Should_Invoke_SendCloudToDeviceMessageAsync() { // arrange - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - var loRaDeviceRegistry = new Mock(); - var devEui = new DevEui(0); - var c2d = JsonSerializer.Serialize(new { DevEui = devEui.ToString(), Fport = 1 }); + var json = @"{""foo"":""bar""}"; + var methodRequest = new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(json)); // act - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceCloseConnection, Encoding.UTF8.GetBytes(c2d)), null); + await this.subject.OnDirectMethodCalled(methodRequest, null); // assert - Assert.Equal((int)HttpStatusCode.NotFound, response.Status); - loRaDeviceRegistry.Verify(x => x.GetDeviceByDevEUIAsync(devEui), Times.Once); - loRaDeviceRegistry.VerifyNoOtherCalls(); - } - - [Fact] - public async Task OnDirectMethodCall_CloudToDeviceDecoderElementName_When_Correct_Should_Work() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - var c2d = "{\"test\":\"asd\"}"; - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(c2d), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)), null); - Assert.Equal((int)HttpStatusCode.OK, response.Status); + this.lnsRemoteCall.Verify(l => l.ExecuteAsync(new LnsRemoteCall(RemoteCallKind.CloudToDeviceMessage, json), CancellationToken.None), Times.Once); } [Fact] public async Task OnDirectMethodCall_When_Null_Or_Empty_MethodName_Should_Throw() { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - await Assert.ThrowsAnyAsync(async () => await moduleClient.OnDirectMethodCalled(null, null)); + await Assert.ThrowsAnyAsync(async () => await this.subject.OnDirectMethodCalled(null, null)); } - [Fact] - public async Task OnDirectMethodCall_CloudToDeviceDecoderElementName_When_Incorrect_Should_Return_NotFound() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - var c2d = "{\"test\":\"asd\"}"; - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(this.faker.Random.String2(8), Encoding.UTF8.GetBytes(c2d)), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - } - - [Fact] - public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Null_Or_Empty_Should_Return_Not_Found() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - var loRaModuleClient = new Mock(); - loRaModuleClient.Setup(x => x.DisposeAsync()); - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, null), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - - var response2 = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, Array.Empty()), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response2.Status); - } - - [Fact] - public async Task SendCloudToDeviceMessageAsync_When_ClassC_Msg_Is_Not_CorrectJson_Should_Return_Not_Found() - { - var networkServerConfiguration = new NetworkServerConfiguration(); - var classCMessageSender = new Mock(MockBehavior.Strict); - classCMessageSender.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var loRaDeviceRegistry = new Mock(MockBehavior.Strict); - - // Change the iot edge timeout. - networkServerConfiguration.IoTEdgeTimeout = 5; - await using var moduleClient = new ModuleConnectionHost(networkServerConfiguration, classCMessageSender.Object, this.loRaModuleClientFactory.Object, loRaDeviceRegistry.Object, loRaDeviceApiServiceBase, NullLogger.Instance, TestMeter.Instance); - - var response = await moduleClient.OnDirectMethodCalled(new MethodRequest(Constants.CloudToDeviceDecoderElementName, Encoding.UTF8.GetBytes(faker.Random.String2(10))), null); - Assert.Equal((int)HttpStatusCode.BadRequest, response.Status); - } + public async ValueTask DisposeAsync() => await this.subject.DisposeAsync(); } }