diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20221004121605_Add EdgeDevice.Designer.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20221004121605_Add EdgeDevice.Designer.cs new file mode 100644 index 000000000..4f65ac584 --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20221004121605_Add EdgeDevice.Designer.cs @@ -0,0 +1,440 @@ +// +using System; +using AzureIoTHub.Portal.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AzureIoTHub.Portal.Infrastructure.Migrations +{ + [DbContext(typeof(PortalDbContext))] + [Migration("20221004121605_Add EdgeDevice")] + partial class AddEdgeDevice + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.Device", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsConnected") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModel", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ABPRelaxMode") + .HasColumnType("boolean"); + + b.Property("AppEUI") + .HasColumnType("text"); + + b.Property("ClassType") + .HasColumnType("integer"); + + b.Property("Deduplication") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Downlink") + .HasColumnType("boolean"); + + b.Property("IsBuiltin") + .HasColumnType("boolean"); + + b.Property("KeepAliveTimeout") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PreferredWindow") + .HasColumnType("integer"); + + b.Property("RXDelay") + .HasColumnType("integer"); + + b.Property("SensorDecoder") + .HasColumnType("text"); + + b.Property("SupportLoRaFeatures") + .HasColumnType("boolean"); + + b.Property("UseOTAA") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("DeviceModels"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModelCommand", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Confirmed") + .HasColumnType("boolean"); + + b.Property("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Frame") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsBuiltin") + .HasColumnType("boolean"); + + b.Property("Port") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("DeviceModelCommands"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceModelProperty", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsWritable") + .HasColumnType("boolean"); + + b.Property("ModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PropertyType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("DeviceModelProperties"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceTag", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("Searchable") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("DeviceTags"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceTagValue", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("EdgeDeviceId") + .HasColumnType("text"); + + b.Property("LorawanDeviceId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("EdgeDeviceId"); + + b.HasIndex("LorawanDeviceId"); + + b.ToTable("DeviceTagValues"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDevice", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NbDevices") + .HasColumnType("integer"); + + b.Property("NbModules") + .HasColumnType("integer"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EdgeDevices"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDeviceModel", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EdgeDeviceModels"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDeviceModelCommand", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("EdgeDeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModuleName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EdgeDeviceModelCommands"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ABPRelaxMode") + .HasColumnType("boolean"); + + b.Property("AlreadyLoggedInOnce") + .HasColumnType("boolean"); + + b.Property("AppEUI") + .HasColumnType("text"); + + b.Property("AppKey") + .HasColumnType("text"); + + b.Property("AppSKey") + .HasColumnType("text"); + + b.Property("ClassType") + .HasColumnType("integer"); + + b.Property("DataRate") + .HasColumnType("text"); + + b.Property("Deduplication") + .HasColumnType("integer"); + + b.Property("DevAddr") + .HasColumnType("text"); + + b.Property("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Downlink") + .HasColumnType("boolean"); + + b.Property("FCntDownStart") + .HasColumnType("integer"); + + b.Property("FCntResetCounter") + .HasColumnType("integer"); + + b.Property("FCntUpStart") + .HasColumnType("integer"); + + b.Property("GatewayID") + .HasColumnType("text"); + + b.Property("IsConnected") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("KeepAliveTimeout") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NbRep") + .HasColumnType("text"); + + b.Property("NwkSKey") + .HasColumnType("text"); + + b.Property("PreferredWindow") + .HasColumnType("integer"); + + b.Property("RX1DROffset") + .HasColumnType("integer"); + + b.Property("RX2DataRate") + .HasColumnType("integer"); + + b.Property("RXDelay") + .HasColumnType("integer"); + + b.Property("ReportedRX1DROffset") + .HasColumnType("text"); + + b.Property("ReportedRX2DataRate") + .HasColumnType("text"); + + b.Property("ReportedRXDelay") + .HasColumnType("text"); + + b.Property("SensorDecoder") + .HasColumnType("text"); + + b.Property("StatusUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Supports32BitFCnt") + .HasColumnType("boolean"); + + b.Property("TxPower") + .HasColumnType("text"); + + b.Property("UseOTAA") + .HasColumnType("boolean"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("LorawanDevices"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.DeviceTagValue", b => + { + b.HasOne("AzureIoTHub.Portal.Domain.Entities.Device", null) + .WithMany("Tags") + .HasForeignKey("DeviceId"); + + b.HasOne("AzureIoTHub.Portal.Domain.Entities.EdgeDevice", null) + .WithMany("Tags") + .HasForeignKey("EdgeDeviceId"); + + b.HasOne("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", null) + .WithMany("Tags") + .HasForeignKey("LorawanDeviceId"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.Device", b => + { + b.Navigation("Tags"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDevice", b => + { + b.Navigation("Tags"); + }); + + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", b => + { + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/20221004121605_Add EdgeDevice.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20221004121605_Add EdgeDevice.cs new file mode 100644 index 000000000..e160ca2ae --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/20221004121605_Add EdgeDevice.cs @@ -0,0 +1,55 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable disable + +namespace AzureIoTHub.Portal.Infrastructure.Migrations +{ + using Microsoft.EntityFrameworkCore.Migrations; + + public partial class AddEdgeDevice : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + _ = migrationBuilder.AddColumn( + name: "EdgeDeviceId", + table: "DeviceTagValues", + type: "text", + nullable: true); + + _ = migrationBuilder.CreateTable( + name: "EdgeDevices", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + DeviceModelId = table.Column(type: "text", nullable: false), + Version = table.Column(type: "integer", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + Scope = table.Column(type: "text", nullable: false), + NbDevices = table.Column(type: "integer", nullable: false), + NbModules = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + _ = table.PrimaryKey("PK_EdgeDevices", x => x.Id); + }); + + _ = migrationBuilder.CreateIndex(name: "IX_DeviceTagValues_EdgeDeviceId", table: "DeviceTagValues", column: "EdgeDeviceId"); + + _ = migrationBuilder.AddForeignKey(name: "FK_DeviceTagValues_EdgeDevices_EdgeDeviceId", table: "DeviceTagValues", column: "EdgeDeviceId", principalTable: "EdgeDevices", principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + _ = migrationBuilder.DropForeignKey(name: "FK_DeviceTagValues_EdgeDevices_EdgeDeviceId", table: "DeviceTagValues"); + + _ = migrationBuilder.DropTable( + name: "EdgeDevices"); + + _ = migrationBuilder.DropIndex(name: "IX_DeviceTagValues_EdgeDeviceId", table: "DeviceTagValues"); + + _ = migrationBuilder.DropColumn(name: "EdgeDeviceId", table: "DeviceTagValues"); + } + } +} diff --git a/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs b/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs index 195f59bb7..de64f2bca 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/Migrations/PortalDbContextModelSnapshot.cs @@ -226,6 +226,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeviceId") .HasColumnType("text"); + b.Property("EdgeDeviceId") + .HasColumnType("text"); + b.Property("LorawanDeviceId") .HasColumnType("text"); @@ -241,11 +244,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DeviceId"); + b.HasIndex("EdgeDeviceId"); + b.HasIndex("LorawanDeviceId"); b.ToTable("DeviceTagValues"); }); + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDevice", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DeviceModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NbDevices") + .HasColumnType("integer"); + + b.Property("NbModules") + .HasColumnType("integer"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EdgeDevices"); + }); + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDeviceModel", b => { b.Property("Id") @@ -405,6 +444,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany("Tags") .HasForeignKey("DeviceId"); + b.HasOne("AzureIoTHub.Portal.Domain.Entities.EdgeDevice", null) + .WithMany("Tags") + .HasForeignKey("EdgeDeviceId"); + b.HasOne("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", null) .WithMany("Tags") .HasForeignKey("LorawanDeviceId"); @@ -415,6 +458,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tags"); }); + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.EdgeDevice", b => + { + b.Navigation("Tags"); + }); + modelBuilder.Entity("AzureIoTHub.Portal.Domain.Entities.LorawanDevice", b => { b.Navigation("Tags"); diff --git a/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs b/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs index b84c9a7fc..daed92731 100644 --- a/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs +++ b/src/AzureIoTHub.Portal.Infrastructure/PortalDbContext.cs @@ -14,6 +14,7 @@ public class PortalDbContext : DbContext public DbSet DeviceModelCommands { get; set; } public DbSet DeviceModels { get; set; } public DbSet Devices { get; set; } + public DbSet EdgeDevices { get; set; } public DbSet LorawanDevices { get; set; } public DbSet DeviceTagValues { get; set; } public DbSet EdgeDeviceModels { get; set; } diff --git a/src/AzureIoTHub.Portal.Infrastructure/Repositories/EdgeDeviceRepository.cs b/src/AzureIoTHub.Portal.Infrastructure/Repositories/EdgeDeviceRepository.cs new file mode 100644 index 000000000..2c720aa00 --- /dev/null +++ b/src/AzureIoTHub.Portal.Infrastructure/Repositories/EdgeDeviceRepository.cs @@ -0,0 +1,25 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Infrastructure.Repositories +{ + using System.Threading.Tasks; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Domain.Repositories; + using Microsoft.EntityFrameworkCore; + + public class EdgeDeviceRepository : GenericRepository, IEdgeDeviceRepository + { + public EdgeDeviceRepository(PortalDbContext context) : base(context) + { + } + + public override Task GetByIdAsync(object id) + { + return this.context.Set() + .Include(device => device.Tags) + .Where(device => device.Id.Equals(id.ToString())) + .FirstOrDefaultAsync(); + } + } +} diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/EdgeDeviceRepositoryTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/EdgeDeviceRepositoryTest.cs new file mode 100644 index 000000000..af21f9911 --- /dev/null +++ b/src/AzureIoTHub.Portal.Tests.Unit/Infrastructure/Repositories/EdgeDeviceRepositoryTest.cs @@ -0,0 +1,60 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Tests.Unit.Infrastructure.Repositories +{ + using System.Linq; + using System.Threading.Tasks; + using AutoFixture; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Infrastructure.Repositories; + using AzureIoTHub.Portal.Tests.Unit.UnitTests.Bases; + using FluentAssertions; + using NUnit.Framework; + + public class EdgeDeviceRepositoryTest : BackendUnitTest + { + private EdgeDeviceRepository edgeDeviceRepository; + + public override void Setup() + { + base.Setup(); + + this.edgeDeviceRepository = new EdgeDeviceRepository(DbContext); + } + + [Test] + public async Task GetAllShouldReturnExpectedEdgeDevices() + { + // Arrange + var expectedEdgeDevices = Fixture.CreateMany(5).ToList(); + + await DbContext.AddRangeAsync(expectedEdgeDevices); + + _ = await DbContext.SaveChangesAsync(); + + //Act + var result = this.edgeDeviceRepository.GetAll().ToList(); + + // Assert + _ = result.Should().BeEquivalentTo(expectedEdgeDevices); + } + + [Test] + public async Task GetByIdAsyncExistingDeviceReturnsExpectedEdgeDevice() + { + // Arrange + var expectedEdgeDevices = Fixture.Create(); + + _ = DbContext.Add(expectedEdgeDevices); + + _ = await DbContext.SaveChangesAsync(); + + //Act + var result = await this.edgeDeviceRepository.GetByIdAsync(expectedEdgeDevices.Id); + + // Assert + _ = result.Should().BeEquivalentTo(expectedEdgeDevices); + } + } +} diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncEdgeDeviceJobTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncEdgeDeviceJobTest.cs new file mode 100644 index 000000000..065131c91 --- /dev/null +++ b/src/AzureIoTHub.Portal.Tests.Unit/Server/Jobs/SyncEdgeDeviceJobTest.cs @@ -0,0 +1,205 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Tests.Unit.Server.Jobs +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using AutoFixture; + using AzureIoTHub.Portal.Domain; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Domain.Repositories; + using AzureIoTHub.Portal.Server.Jobs; + using AzureIoTHub.Portal.Server.Services; + using AzureIoTHub.Portal.Tests.Unit.UnitTests.Bases; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using Moq; + using NUnit.Framework; + using Quartz; + + public class SyncEdgeDeviceJobTest : BackendUnitTest + { + private IJob syncEdgeDeviceJob; + + private Mock mockExternalDeviceService; + private Mock mockEdgeDeviceRepository; + private Mock mockDeviceTagValueRepository; + private Mock mockEdgeDeviceModelRepository; + private Mock mockUnitOfWork; + private Mock mockEdgeDevicesService; + private Mock> mockLogger; + + public override void Setup() + { + base.Setup(); + + this.mockExternalDeviceService = MockRepository.Create(); + this.mockEdgeDeviceRepository = MockRepository.Create(); + this.mockDeviceTagValueRepository = MockRepository.Create(); + this.mockEdgeDeviceModelRepository = MockRepository.Create(); + this.mockUnitOfWork = MockRepository.Create(); + this.mockEdgeDevicesService = MockRepository.Create(); + this.mockLogger = MockRepository.Create>(); + + _ = ServiceCollection.AddSingleton(this.mockExternalDeviceService.Object); + _ = ServiceCollection.AddSingleton(this.mockEdgeDeviceRepository.Object); + _ = ServiceCollection.AddSingleton(this.mockDeviceTagValueRepository.Object); + _ = ServiceCollection.AddSingleton(this.mockEdgeDeviceModelRepository.Object); + _ = ServiceCollection.AddSingleton(this.mockUnitOfWork.Object); + _ = ServiceCollection.AddSingleton(this.mockEdgeDevicesService.Object); + _ = ServiceCollection.AddSingleton(); + + Services = ServiceCollection.BuildServiceProvider(); + + this.syncEdgeDeviceJob = Services.GetRequiredService(); + } + + [Test] + public async Task ExecuteNewEdgeDeviceDeviceCreated() + { + // Arrange + var mockJobExecutionContext = MockRepository.Create(); + + var expectedDeviceModel = Fixture.Create(); + + var expectedTwinDevice = new Twin + { + DeviceId = Fixture.Create(), + Tags = new TwinCollection + { + ["modelId"] = expectedDeviceModel.Id, + ["deviceName"] = Fixture.Create(), + ["test"] = Fixture.Create() + }, + Capabilities = new DeviceCapabilities{ IotEdge = true } + }; + + _ = this.mockExternalDeviceService + .Setup(x => x.GetAllEdgeDevice( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is(x => x == 100))) + .ReturnsAsync(new PaginationResult + { + Items = new List + { + expectedTwinDevice + }, + TotalItems = 1 + }); + + _ = this.mockExternalDeviceService + .Setup(x => x.GetDeviceTwinWithModule(It.Is(c => c.Equals(expectedTwinDevice.DeviceId, System.StringComparison.Ordinal)))) + .ReturnsAsync(expectedTwinDevice); + + _ = this.mockExternalDeviceService + .Setup(x => x.GetDeviceTwinWithEdgeHubModule(It.Is(c => c.Equals(expectedTwinDevice.DeviceId, System.StringComparison.Ordinal)))) + .ReturnsAsync(expectedTwinDevice); + + _ = this.mockEdgeDeviceModelRepository + .Setup(x => x.GetByIdAsync(expectedDeviceModel.Id)) + .ReturnsAsync(expectedDeviceModel); + + _ = this.mockEdgeDeviceRepository.Setup(x => x.GetByIdAsync(expectedTwinDevice.DeviceId)). + ReturnsAsync(value: null); + + _ = this.mockEdgeDeviceRepository.Setup(x => x.InsertAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + _ = this.mockUnitOfWork.Setup(work => work.SaveAsync()) + .Returns(Task.CompletedTask); + + // Act + await this.syncEdgeDeviceJob.Execute(mockJobExecutionContext.Object); + + // Assert + MockRepository.VerifyAll(); + } + + [Test] + public async Task ExecuteExistingEdgeDeviceWithGreeterVersionDeviceUpdated() + { + // Arrange + var mockJobExecutionContext = MockRepository.Create(); + + var expectedDeviceModel = Fixture.Create(); + + var expectedTwinDevice = new Twin + { + DeviceId = Fixture.Create(), + Tags = new TwinCollection + { + ["modelId"] = expectedDeviceModel.Id, + ["deviceName"] = Fixture.Create() + }, + Capabilities = new DeviceCapabilities{ IotEdge = true }, + Version = 2 + }; + + var existingDevice = new EdgeDevice + { + Id = expectedTwinDevice.DeviceId, + Version = 1, + Tags = new List + { + new() + { + Id = Fixture.Create(), + Name = Fixture.Create(), + Value = Fixture.Create() + } + } + }; + + _ = this.mockExternalDeviceService + .Setup(x => x.GetAllEdgeDevice( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is(x => x == 100))) + .ReturnsAsync(new PaginationResult + { + Items = new List + { + expectedTwinDevice + }, + TotalItems = 1 + }); + + _ = this.mockExternalDeviceService + .Setup(x => x.GetDeviceTwinWithModule(It.Is(c => c.Equals(expectedTwinDevice.DeviceId, System.StringComparison.Ordinal)))) + .ReturnsAsync(expectedTwinDevice); + + _ = this.mockExternalDeviceService + .Setup(x => x.GetDeviceTwinWithEdgeHubModule(It.Is(c => c.Equals(expectedTwinDevice.DeviceId, System.StringComparison.Ordinal)))) + .ReturnsAsync(expectedTwinDevice); + + _ = this.mockEdgeDeviceModelRepository + .Setup(x => x.GetByIdAsync(expectedDeviceModel.Id)) + .ReturnsAsync(expectedDeviceModel); + + _ = this.mockEdgeDeviceRepository.Setup(x => x.GetByIdAsync(expectedTwinDevice.DeviceId)). + ReturnsAsync(existingDevice); + + this.mockDeviceTagValueRepository.Setup(repository => repository.Delete(It.IsAny())) + .Verifiable(); + + this.mockEdgeDeviceRepository.Setup(repository => repository.Update(It.IsAny())) + .Verifiable(); + + _ = this.mockUnitOfWork.Setup(work => work.SaveAsync()) + .Returns(Task.CompletedTask); + + // Act + await this.syncEdgeDeviceJob.Execute(mockJobExecutionContext.Object); + + // Assert + MockRepository.VerifyAll(); + } + } +} diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/EdgeDeviceServiceTest.cs b/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/EdgeDeviceServiceTest.cs index f44de73eb..71af181a0 100644 --- a/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/EdgeDeviceServiceTest.cs +++ b/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/EdgeDeviceServiceTest.cs @@ -128,14 +128,9 @@ public async Task GetEdgeDeviceShouldReturnValue() 1, 2 }; - var mockQuery = this.mockRepository.Create(); - _ = mockQuery.Setup(c => c.GetNextAsTwinAsync()) - .ReturnsAsync(new[] { edgeHubTwin }); - - _ = this.mockRegistryManager.Setup(c => c.CreateQuery( - It.Is(x => x == $"SELECT * FROM devices.modules WHERE devices.modules.moduleId = '$edgeHub' AND deviceId in ['{expectedDevice.DeviceId}']"), - It.Is(x => x == 1))) - .Returns(mockQuery.Object); + _ = this.mockDeviceService + .Setup(x => x.GetDeviceTwinWithEdgeHubModule(It.Is(c => c.Equals(expectedDevice.DeviceId, StringComparison.Ordinal)))) + .ReturnsAsync(edgeHubTwin); _ = this.mockEdgeDeviceMapper .Setup(x => x.CreateEdgeDevice(It.Is(c => c.DeviceId.Equals(expectedDevice.DeviceId, StringComparison.Ordinal)), diff --git a/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/ExternalDeviceServiceTests.cs b/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/ExternalDeviceServiceTests.cs index 7fe8c42eb..9193abcae 100644 --- a/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/ExternalDeviceServiceTests.cs +++ b/src/AzureIoTHub.Portal.Tests.Unit/Server/Services/ExternalDeviceServiceTests.cs @@ -18,6 +18,7 @@ namespace AzureIoTHub.Portal.Tests.Unit.Server.Services using Microsoft.Azure.Devices; using Microsoft.Azure.Devices.Shared; using Microsoft.Extensions.Logging; + using Microsoft.Graph.ExternalConnectors; using Models.v10; using Moq; using Newtonsoft.Json; @@ -828,6 +829,58 @@ public async Task GetDeviceTwinWithModuleStateUnderTestExpectedBehavior() this.mockRepository.VerifyAll(); } + [Test] + public async Task GetDeviceTwinWithEdgeHubModuleStateUnderTestExpectedBehavior() + { + // Arrange + var service = CreateService(); + var deviceId = Guid.NewGuid().ToString(); + + var mockQuery = this.mockRepository.Create(); + + _ = mockQuery.Setup(c => c.GetNextAsTwinAsync()) + .ReturnsAsync(new Twin[] + { + new Twin(Guid.NewGuid().ToString()) + }); + + _ = this.mockRegistryManager.Setup(c => c.CreateQuery( + It.Is(x => x == $"SELECT * FROM devices.modules WHERE devices.modules.moduleId = '$edgeHub' AND deviceId in ['{deviceId}']"), It.Is(x => x == 1))) + .Returns(mockQuery.Object); + + // Act + var result = await service.GetDeviceTwinWithEdgeHubModule(deviceId); + + // Assert + Assert.IsNotNull(result); + Assert.IsAssignableFrom(result); + this.mockRepository.VerifyAll(); + } + + [Test] + public async Task GetDeviceTwinWithEdgeHubModule() + { + // Arrange + var service = CreateService(); + var deviceId = Guid.NewGuid().ToString(); + + var mockQuery = this.mockRepository.Create(); + + _ = mockQuery.Setup(c => c.GetNextAsTwinAsync()) + .ThrowsAsync(new Exception("")); + + _ = this.mockRegistryManager.Setup(c => c.CreateQuery( + It.Is(x => x == $"SELECT * FROM devices.modules WHERE devices.modules.moduleId = '$edgeHub' AND deviceId in ['{deviceId}']"), It.Is(x => x == 1))) + .Returns(mockQuery.Object); + + // Act + var act = async () => await service.GetDeviceTwinWithEdgeHubModule(deviceId); + + // Assert + _ = await act.Should().ThrowAsync(); + this.mockRepository.VerifyAll(); + } + [TestCase(false, DeviceStatus.Enabled)] [TestCase(true, DeviceStatus.Enabled)] [TestCase(false, DeviceStatus.Disabled)] diff --git a/src/AzureIoTHub.Portal/Server/Jobs/SyncEdgeDeviceJob.cs b/src/AzureIoTHub.Portal/Server/Jobs/SyncEdgeDeviceJob.cs new file mode 100644 index 000000000..8765e8d9a --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Jobs/SyncEdgeDeviceJob.cs @@ -0,0 +1,136 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Server.Jobs +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using AutoMapper; + using AzureIoTHub.Portal.Domain; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Domain.Repositories; + using AzureIoTHub.Portal.Server.Services; + using Microsoft.Azure.Devices.Shared; + using Microsoft.Extensions.Logging; + using Quartz; + + [DisallowConcurrentExecution] + public class SyncEdgeDeviceJob : IJob + { + private readonly IExternalDeviceService externalDeviceService; + private readonly IEdgeDeviceRepository edgeDeviceRepository; + private readonly IDeviceTagValueRepository deviceTagValueRepository; + private readonly IEdgeDeviceModelRepository edgeDeviceModelRepository; + private readonly IMapper mapper; + private readonly IUnitOfWork unitOfWork; + private readonly ILogger logger; + + private readonly IEdgeDevicesService edgeDevicesService; + + private const string ModelId = "modelId"; + + public SyncEdgeDeviceJob(IExternalDeviceService externalDeviceService, + IEdgeDeviceModelRepository edgeDeviceModelRepository, + IEdgeDevicesService edgeDevicesService, + IEdgeDeviceRepository edgeDeviceRepository, + IDeviceTagValueRepository deviceTagValueRepository, + IMapper mapper, + IUnitOfWork unitOfWork, + ILogger logger) + { + this.edgeDeviceModelRepository = edgeDeviceModelRepository; + this.externalDeviceService = externalDeviceService; + this.edgeDeviceRepository = edgeDeviceRepository; + this.deviceTagValueRepository = deviceTagValueRepository; + this.mapper = mapper; + this.unitOfWork = unitOfWork; + this.logger = logger; + this.edgeDevicesService = edgeDevicesService; + } + + public async Task Execute(IJobExecutionContext context) + { + try + { + this.logger.LogInformation("Start of sync devices job"); + + await SyncEdgeDevices(); + + this.logger.LogInformation("End of sync devices job"); + } + catch (Exception e) + { + this.logger.LogError(e, "Sync devices job has failed"); + } + } + + private async Task SyncEdgeDevices() + { + foreach (var twin in await GetTwinDevices()) + { + var deviceModel = await this.edgeDeviceModelRepository.GetByIdAsync(twin.Tags[ModelId]?.ToString() ?? string.Empty); + + if (deviceModel == null) + { + this.logger.LogWarning($"The device with wont be synched, its model id {twin.Tags[ModelId]?.ToString()} doesn't exist"); + continue; + } + + await CreateOrUpdateDevice(twin); + } + + await this.unitOfWork.SaveAsync(); + } + + private async Task> GetTwinDevices() + { + var twins = new List(); + var continuationToken = string.Empty; + + int totalTwinDevices; + do + { + var result = await this.externalDeviceService.GetAllEdgeDevice(continuationToken: continuationToken, pageSize: 100); + twins.AddRange(result.Items); + + totalTwinDevices = result.TotalItems; + continuationToken = result.NextPage; + + } while (totalTwinDevices > twins.Count); + + return twins; + } + + private async Task CreateOrUpdateDevice(Twin twin) + { + var twinWithModule = await this.externalDeviceService.GetDeviceTwinWithModule(twin.DeviceId); + var twinWithClient = await this.externalDeviceService.GetDeviceTwinWithEdgeHubModule(twin.DeviceId); + + var device = this.mapper.Map(twin,opts => + { + opts.Items["TwinModules"] = twinWithModule; + opts.Items["TwinClient"] = twinWithClient; + }) ; + + var deviceEntity = await this.edgeDeviceRepository.GetByIdAsync(device.Id); + + if (deviceEntity == null) + { + await this.edgeDeviceRepository.InsertAsync(device); + } + else + { + if (deviceEntity.Version >= device.Version) return; + + foreach (var deviceTagEntity in deviceEntity.Tags) + { + this.deviceTagValueRepository.Delete(deviceTagEntity.Id); + } + + _ = this.mapper.Map(device, deviceEntity); + this.edgeDeviceRepository.Update(deviceEntity); + } + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Mappers/EdgeDeviceProfile.cs b/src/AzureIoTHub.Portal/Server/Mappers/EdgeDeviceProfile.cs new file mode 100644 index 000000000..7577aa544 --- /dev/null +++ b/src/AzureIoTHub.Portal/Server/Mappers/EdgeDeviceProfile.cs @@ -0,0 +1,52 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Server.Mappers +{ + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using AutoMapper; + using AzureIoTHub.Portal.Domain.Entities; + using AzureIoTHub.Portal.Server.Helpers; + using Microsoft.Azure.Devices.Shared; + using Newtonsoft.Json.Linq; + + public class EdgeDeviceProfile : Profile + { + public EdgeDeviceProfile() + { + _ = CreateMap(); + + _ = CreateMap() + .ForMember(dest => dest.Id, opts => opts.MapFrom(src => src.DeviceId)) + .ForMember(dest => dest.Name, opts => opts.MapFrom(src => src.Tags["deviceName"])) + .ForMember(dest => dest.DeviceModelId, opts => opts.MapFrom(src => src.Tags["modelId"])) + .ForMember(dest => dest.Version, opts => opts.MapFrom(src => src.Version)) + .ForMember(dest => dest.IsEnabled, opts => opts.MapFrom(src => src.Status == Microsoft.Azure.Devices.DeviceStatus.Enabled)) + .ForMember(dest => dest.Tags, opts => opts.MapFrom(src => GetTags(src))) + .ForMember(dest => dest.Scope, opts => opts.MapFrom(src => src.DeviceScope)) + .ForMember(dest => dest.NbDevices, opts => opts.MapFrom((src, _, _, context) => GetNbConnectedDevice((Twin)context.Items["TwinClient"]))) + .ForMember(dest => dest.NbModules, opts => opts.MapFrom((src, _, _, context) => DeviceHelper.RetrieveNbModuleCount((Twin)context.Items["TwinModules"], src.DeviceId))); + } + + private static ICollection GetTags(Twin twin) + { + return (JsonSerializer.Deserialize>(twin.Tags.ToJson()) ?? new Dictionary()) + .Where(tag => tag.Key is not "modelId" and not "deviceName") + .Select(tag => new DeviceTagValue + { + Name = tag.Key, + Value = tag.Value.ToString() ?? string.Empty + }) + .ToList(); + } + + private static int GetNbConnectedDevice(Twin twin) + { + var reportedProperties = JObject.Parse(twin.Properties.Reported.ToJson()); + + return reportedProperties.TryGetValue("clients", out var clients) ? clients.Count() : 0; + } + } +} diff --git a/src/AzureIoTHub.Portal/Server/Services/EdgeDevicesService.cs b/src/AzureIoTHub.Portal/Server/Services/EdgeDevicesService.cs index e1e9fd09c..640e91a71 100644 --- a/src/AzureIoTHub.Portal/Server/Services/EdgeDevicesService.cs +++ b/src/AzureIoTHub.Portal/Server/Services/EdgeDevicesService.cs @@ -255,8 +255,7 @@ public async Task GetEdgeDeviceCredentials(string edgeDev /// The device identifier. private async Task RetrieveNbConnectedDevice(string deviceId) { - var query = this.registryManager.CreateQuery($"SELECT * FROM devices.modules WHERE devices.modules.moduleId = '$edgeHub' AND deviceId in ['{deviceId}']", 1); - var deviceWithClient = (await query.GetNextAsTwinAsync()).SingleOrDefault(); + var deviceWithClient = await this.externalDevicesService.GetDeviceTwinWithEdgeHubModule(deviceId); var reportedProperties = JObject.Parse(deviceWithClient.Properties.Reported.ToJson()); return reportedProperties.TryGetValue("clients", out var clients) ? clients.Count() : 0; diff --git a/src/AzureIoTHub.Portal/Server/Services/ExternalDeviceService.cs b/src/AzureIoTHub.Portal/Server/Services/ExternalDeviceService.cs index d851993d5..9a9d05b6d 100644 --- a/src/AzureIoTHub.Portal/Server/Services/ExternalDeviceService.cs +++ b/src/AzureIoTHub.Portal/Server/Services/ExternalDeviceService.cs @@ -44,7 +44,7 @@ public ExternalDeviceService( } /// - /// this function return a list of all edge device wthiout tags. + /// this function return a list of all edge device without modules. /// /// /// @@ -315,6 +315,21 @@ public async Task GetDeviceTwinWithModule(string deviceId) return null; } + public async Task GetDeviceTwinWithEdgeHubModule(string deviceId) + { + var query = this.registryManager.CreateQuery($"SELECT * FROM devices.modules WHERE devices.modules.moduleId = '$edgeHub' AND deviceId in ['{deviceId}']", 1); + + try + { + var devicesTwins = await query.GetNextAsTwinAsync(); + return devicesTwins.ElementAt(0); + } + catch (Exception e) + { + throw new InternalServerErrorException($"Unable to get devices twins", e); + } + } + /// /// This function create a new device with his twin. /// diff --git a/src/AzureIoTHub.Portal/Server/Services/IExternalDeviceService.cs b/src/AzureIoTHub.Portal/Server/Services/IExternalDeviceService.cs index 5ff2b00a1..198beddac 100644 --- a/src/AzureIoTHub.Portal/Server/Services/IExternalDeviceService.cs +++ b/src/AzureIoTHub.Portal/Server/Services/IExternalDeviceService.cs @@ -16,6 +16,7 @@ public interface IExternalDeviceService Task GetDeviceTwin(string deviceId); Task GetDeviceTwinWithModule(string deviceId); + Task GetDeviceTwinWithEdgeHubModule(string deviceId); Task CreateDeviceWithTwin(string deviceId, bool isEdge, Twin twin, DeviceStatus isEnabled); diff --git a/src/AzureIoTHub.Portal/Server/Startup.cs b/src/AzureIoTHub.Portal/Server/Startup.cs index 2b015afc4..84a3653d4 100644 --- a/src/AzureIoTHub.Portal/Server/Startup.cs +++ b/src/AzureIoTHub.Portal/Server/Startup.cs @@ -152,6 +152,7 @@ public void ConfigureServices(IServiceCollection services) _ = services.AddScoped(); _ = services.AddScoped(); _ = services.AddScoped(); + _ = services.AddScoped(); _ = services.AddScoped(); _ = services.AddScoped(); _ = services.AddScoped(); @@ -323,9 +324,15 @@ Specify the authorization token got from your IDP as a header. .WithSimpleSchedule(s => s .WithIntervalInMinutes(configuration.SyncDatabaseJobRefreshIntervalInMinutes) .RepeatForever())); - }); - + _ = q.AddJob(j => j.WithIdentity(nameof(SyncEdgeDeviceJob))) + .AddTrigger(t => t + .WithIdentity($"{nameof(SyncEdgeDeviceJob)}") + .ForJob(nameof(SyncEdgeDeviceJob)) + .WithSimpleSchedule(s => s + .WithIntervalInMinutes(configuration.SyncDatabaseJobRefreshIntervalInMinutes) + .RepeatForever())); + }); // Add the Quartz.NET hosted service _ = services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); diff --git a/src/AzureIoTHubPortal.Domain/Entities/EdgeDevice.cs b/src/AzureIoTHubPortal.Domain/Entities/EdgeDevice.cs new file mode 100644 index 000000000..b4d0d3442 --- /dev/null +++ b/src/AzureIoTHubPortal.Domain/Entities/EdgeDevice.cs @@ -0,0 +1,41 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Domain.Entities +{ + using AzureIoTHub.Portal.Domain.Base; + + public class EdgeDevice : EntityBase + { + public string Name { get; set; } + + public string DeviceModelId { get; set; } + + public int Version { get; set; } + + /// + /// true if this instance is enabled; otherwise, false. + /// + public bool IsEnabled { get; set; } + + /// + /// The IoT Edge scope tag value. + /// + public string Scope { get; set; } + + /// + /// The number of connected devices on IoT Edge device. + /// + public int NbDevices { get; set; } + + /// + /// The number of modules on IoT Edge device. + /// + public int NbModules { get; set; } + + /// + /// List of custom device tags and their values. + /// + public ICollection Tags { get; set; } + } +} diff --git a/src/AzureIoTHubPortal.Domain/Repositories/IEdgeDeviceRepository.cs b/src/AzureIoTHubPortal.Domain/Repositories/IEdgeDeviceRepository.cs new file mode 100644 index 000000000..ef4db7f23 --- /dev/null +++ b/src/AzureIoTHubPortal.Domain/Repositories/IEdgeDeviceRepository.cs @@ -0,0 +1,11 @@ +// Copyright (c) CGI France. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace AzureIoTHub.Portal.Domain.Repositories +{ + using AzureIoTHub.Portal.Domain.Entities; + + public interface IEdgeDeviceRepository : IRepository + { + } +}