diff --git a/source/AAS.TwinEngine.DataEngine.ModuleTests/Api/Services/SubmodelRegistry/SubmodelDescriptorControllerTests.cs b/source/AAS.TwinEngine.DataEngine.ModuleTests/Api/Services/SubmodelRegistry/SubmodelDescriptorControllerTests.cs index a849232d..59ec0d99 100644 --- a/source/AAS.TwinEngine.DataEngine.ModuleTests/Api/Services/SubmodelRegistry/SubmodelDescriptorControllerTests.cs +++ b/source/AAS.TwinEngine.DataEngine.ModuleTests/Api/Services/SubmodelRegistry/SubmodelDescriptorControllerTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json.Nodes; @@ -61,7 +61,7 @@ public async Task GetAllSubmodelDescriptorsAsync_ReturnsOkAsync() CreateShell("Nameplate") ] }; - _ = _mockAasRepositoryService.GetShellsByFiltersAsync(null, null, null, Arg.Any()) + _ = _mockAasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(shells); _ = _mockSubmodelDescriptorProvider.GetDataForSubmodelDescriptorByIdAsync(Arg.Any(), Arg.Any()) @@ -95,7 +95,7 @@ public async Task GetAllSubmodelDescriptorsAsync_WithNotFound_Returns404Async() CreateShell("MissingSubmodel") ] }; - _ = _mockAasRepositoryService.GetShellsByFiltersAsync(null, null, null, Arg.Any()) + _ = _mockAasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(shells); _ = _mockSubmodelDescriptorProvider.GetDataForSubmodelDescriptorByIdAsync(Arg.Any(), Arg.Any()) .Throws(new ResourceNotFoundException()); diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/Handler/SubmodelRepositoryHandlerTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/Handler/SubmodelRepositoryHandlerTests.cs index 83b2b68b..131479c5 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/Handler/SubmodelRepositoryHandlerTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/Handler/SubmodelRepositoryHandlerTests.cs @@ -309,7 +309,7 @@ public async Task HandleSubmodelElement_NullOrEmptyIdShortPath_ThrowsInvalidUser [Fact] public async Task GetAllSubmodels_ReturnsSubmodelsDto_WhenServiceSucceeds() { - var request = new GetAllSubmodelsRequest(); + var request = new GetAllSubmodelsRequest(null, null, null, null, null, null); var submodelList = new SubmodelList { PagingMetaData = new DomainModel.Shared.PagingMetaData(), Result = [] }; _submodelRepository.GetAllSubmodelsAsync(Arg.Any(), Arg.Any(), null, null, Arg.Any()) .Returns(submodelList); @@ -323,7 +323,7 @@ public async Task GetAllSubmodels_ReturnsSubmodelsDto_WhenServiceSucceeds() [Fact] public async Task GetAllSubmodels_WithInvalidLimit_ThrowsInvalidUserInputException() { - var request = new GetAllSubmodelsRequest { Limit = 0 }; + var request = new GetAllSubmodelsRequest(null, null, 0, null, null, null); await Assert.ThrowsAsync(() => _sut.GetAllSubmodels(request, CancellationToken.None)); } @@ -333,7 +333,7 @@ public async Task GetAllSubmodels_BuildsFilterWithSemanticIdAndIdShort() { const string SemanticId = "https://example.com/semanticId"; const string IdShort = "Nameplate"; - var request = new GetAllSubmodelsRequest { SemanticId = SemanticId, IdShort = IdShort }; + var request = new GetAllSubmodelsRequest(SemanticId, IdShort, null, null, null, null); var submodelList = new SubmodelList { PagingMetaData = new DomainModel.Shared.PagingMetaData(), Result = [] }; _submodelRepository.GetAllSubmodelsAsync( Arg.Is(f => f.SemanticId == SemanticId && f.IdShort == IdShort), @@ -356,7 +356,7 @@ await _submodelRepository.Received(1).GetAllSubmodelsAsync( [Fact] public async Task GetAllSubmodels_WhenLevelAndExtentSet_BuildsQueryOptions() { - var request = new GetAllSubmodelsRequest { Level = Level.deep, Extent = Extent.withBlobValue }; + var request = new GetAllSubmodelsRequest(null, null, null, null, Level.deep, Extent.withBlobValue); var submodelList = new SubmodelList { PagingMetaData = new DomainModel.Shared.PagingMetaData(), Result = [] }; _submodelRepository.GetAllSubmodelsAsync( Arg.Any(), diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/SubmodelRepositoryControllerTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/SubmodelRepositoryControllerTests.cs index 0f3152a3..bc4c749b 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/SubmodelRepositoryControllerTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/Api/SubmodelRepository/SubmodelRepositoryControllerTests.cs @@ -141,12 +141,7 @@ public async Task GetAllSubmodelsAsync_PassesQueryParamsToHandler() var expectedDto = new SubmodelsDto { PagingMetaData = new AAS.TwinEngine.DataEngine.Api.Shared.PagingMetaDataDto(), Result = [] }; _handler.GetAllSubmodels(Arg.Any(), Arg.Any()) .Returns(expectedDto); - var request = new GetAllSubmodelsRequest - { - SemanticId = SemanticId, - IdShort = IdShort, - Limit = Limit - }; + var request = new GetAllSubmodelsRequest(SemanticId, IdShort, Limit, null, null, null); await _sut.GetAllSubmodelsAsync(SemanticId, IdShort, Limit, null, CancellationToken.None); diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/AasRepository/AasRepositoryServiceTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/AasRepository/AasRepositoryServiceTests.cs index 0b6c7c64..5e6a97f8 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/AasRepository/AasRepositoryServiceTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/AasRepository/AasRepositoryServiceTests.cs @@ -1,4 +1,4 @@ -using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure; using AAS.TwinEngine.DataEngine.ApplicationLogic.Extensions; using AAS.TwinEngine.DataEngine.Api.SubmodelRepository.Requests; @@ -407,6 +407,8 @@ public async Task GetShellsByFiltersAsync_WithIdShort_QueriesFilteredShellMetada .GetDataForShellsByAssetIdsAsync( manifests, Arg.Is(f => f != null && f.IdShort == targetIdShort), + Arg.Any(), + Arg.Any(), cancellationToken) .Returns(new ShellDescriptorsMetaData { @@ -426,7 +428,7 @@ public async Task GetShellsByFiltersAsync_WithIdShort_QueriesFilteredShellMetada Assert.Single(result.Result); Assert.Equal("aas-1", result.Result[0].Id); await _pluginDataHandler.Received(1) - .GetDataForShellsByAssetIdsAsync(manifests, Arg.Is(f => f != null && f.IdShort == targetIdShort), cancellationToken); + .GetDataForShellsByAssetIdsAsync(manifests, Arg.Is(f => f != null && f.IdShort == targetIdShort), Arg.Any(), Arg.Any(), cancellationToken); } [Fact] diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/Discovery/AssetIdSearchServiceTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/Discovery/AssetIdSearchServiceTests.cs index d3634fd1..33475d7a 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/Discovery/AssetIdSearchServiceTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/Discovery/AssetIdSearchServiceTests.cs @@ -1,4 +1,4 @@ -using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Base; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasRepository; @@ -51,6 +51,8 @@ public async Task SearchShellsByAssetLinkAsync_ReturnsAasIds() _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(metadata); @@ -83,6 +85,8 @@ public async Task SearchShellsByAssetLinkAsync_WithPagination_ReturnsPagedResult _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(metadata); @@ -102,6 +106,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenPluginTimeout_ThrowsPluginNot _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new RequestTimeoutException()); @@ -119,6 +125,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenUnauthorized_ThrowsServiceUnA _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure.UnauthorizedAccessException()); @@ -136,6 +144,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenResponseParsingError_ThrowsIn _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new ResponseParsingException()); @@ -154,6 +164,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenMultiPluginConflict_ThrowsInt _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new MultiPluginConflictException()); @@ -171,6 +183,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenResourceNotFound_ThrowsIntern _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new ResourceNotFoundException()); @@ -200,14 +214,16 @@ public async Task SearchShellsByAssetLinkAsync_FiltersOutEmptyIds() _ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(metadata); - var result3 = await _sut.SearchShellsByAssetLinkAsync(assetLinks, null, null, CancellationToken.None); + var result = await _sut.SearchShellsByAssetLinkAsync(assetLinks, null, null, CancellationToken.None); - Assert.Equal(2, result3.Result!.Count); - Assert.Equal("urn:example:aas:001", result3.Result![0]); - Assert.Equal("urn:example:aas:002", result3.Result![1]); + Assert.Equal(2, result.Result!.Count); + Assert.Equal("urn:example:aas:001", result.Result![0]); + Assert.Equal("urn:example:aas:002", result.Result![1]); } [Fact] diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorServiceTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorServiceTests.cs index c265ab2d..86c4d500 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorServiceTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorServiceTests.cs @@ -7,6 +7,7 @@ using AAS.TwinEngine.DataEngine.DomainModel.AasRepository; using AAS.TwinEngine.DataEngine.DomainModel.Shared; using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRegistry; +using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository; using AAS.TwinEngine.DataEngine.ServiceConfiguration.Config; using AasCore.Aas3_1; @@ -56,7 +57,7 @@ public async Task GetAllSubmodelDescriptorsAsync_ReturnsPagedDescriptorsDerivedF CreateShell("Nameplate") ] }; - _aasRepositoryService.GetShellsByFiltersAsync(null, null, null, Arg.Any()) + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(shells); _submodelTemplateMappingProvider.GetTemplateId("ContactInformation").Returns("ContactInformation"); _submodelTemplateMappingProvider.GetTemplateId("Nameplate").Returns("Nameplate"); @@ -85,7 +86,7 @@ public async Task GetAllSubmodelDescriptorsAsync_SkipsDescriptorWhenSingleDescri CreateShell("MissingSubmodel") ] }; - _aasRepositoryService.GetShellsByFiltersAsync(null, null, null, Arg.Any()) + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(shells); _submodelTemplateMappingProvider.GetTemplateId("ValidSubmodelId").Returns("ValidSubmodelId"); _submodelTemplateMappingProvider.GetTemplateId("MissingSubmodel").Returns("MissingSubmodel"); @@ -111,7 +112,7 @@ public async Task GetAllSubmodelDescriptorsAsync_ThrowsSubmodelDescriptorNotFoun CreateShell("MissingSubmodel2") ] }; - _aasRepositoryService.GetShellsByFiltersAsync(null, null, null, Arg.Any()) + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(shells); _submodelTemplateMappingProvider.GetTemplateId("MissingSubmodel1").Returns("MissingSubmodel1"); _submodelTemplateMappingProvider.GetTemplateId("MissingSubmodel2").Returns("MissingSubmodel2"); @@ -131,7 +132,7 @@ public async Task GetAllSubmodelDescriptorsAsync_ReturnsEmptyResult_WhenShellsHa { Result = [] }; - _aasRepositoryService.GetShellsByFiltersAsync(null, null, null, Arg.Any()) + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(shells); var result = await _sut.GetAllSubmodelDescriptorsAsync(5, null, CancellationToken.None); @@ -277,10 +278,123 @@ public async Task GetSubmodelDescriptorByIdAsync_ThrowsRegistryNotAvailable_When Assert.IsType(ex); } - private static AssetAdministrationShell CreateShell(string submodelId) + [Fact] + public async Task GetAllSubmodelDescriptorsAsync_ResumesCorrectly_WithTwoFieldCompositeCursor() + { + var shell1 = CreateShell("ContactInformation", "shell-1"); + var shell2 = CreateShell("Nameplate", "shell-2"); + + var shellsPage1 = new Shells { Result = [shell1, shell2] }; + var shellsPage2 = new Shells { Result = [shell2] }; + + _aasRepositoryService.GetShellsByFiltersAsync(null, 1, Arg.Is(s => s == null), Arg.Any()) + .Returns(shellsPage1); + _aasRepositoryService.GetShellsByFiltersAsync(null, 1, Arg.Is(s => s != null), Arg.Any()) + .Returns(shellsPage2); + + _submodelTemplateMappingProvider.GetTemplateId("ContactInformation").Returns("ContactInformation"); + _submodelTemplateMappingProvider.GetTemplateId("Nameplate").Returns("Nameplate"); + _provider.GetDataForSubmodelDescriptorByIdAsync("ContactInformation", Arg.Any()) + .Returns(new SubmodelDescriptor { Id = "ContactInformation" }); + _provider.GetDataForSubmodelDescriptorByIdAsync("Nameplate", Arg.Any()) + .Returns(new SubmodelDescriptor { Id = "Nameplate" }); + + var page1 = await _sut.GetAllSubmodelDescriptorsAsync(1, null, CancellationToken.None); + + Assert.NotNull(page1); + Assert.Single(page1.Result!); + Assert.Equal("ContactInformation", page1.Result![0].Id); + Assert.NotNull(page1.PagingMetaData?.Cursor); + + var decodedCursor = SubmodelPaginationCursor.Decode(page1.PagingMetaData.Cursor); + Assert.NotNull(decodedCursor); + Assert.Equal("ContactInformation", decodedCursor.SubmodelId); + Assert.Equal("shell-1", decodedCursor.AasId); + + var page2 = await _sut.GetAllSubmodelDescriptorsAsync(1, page1.PagingMetaData.Cursor, CancellationToken.None); + + Assert.NotNull(page2); + Assert.Single(page2.Result!); + Assert.Equal("Nameplate", page2.Result![0].Id); + } + + [Fact] + public async Task GetAllSubmodelDescriptorsAsync_SkipsShellsWithNullOrEmptyId() + { + var validShell = CreateShell("Nameplate", "valid-shell"); + var nullIdShell = new AssetAdministrationShell( + id: null!, + assetInformation: new AssetInformation(assetKind: AssetKind.Instance, globalAssetId: null), + submodels: [new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, "ShouldBeSkipped")])]); + + var shells = new Shells { Result = [nullIdShell, validShell] }; + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(shells); + _submodelTemplateMappingProvider.GetTemplateId("Nameplate").Returns("Nameplate"); + _provider.GetDataForSubmodelDescriptorByIdAsync("Nameplate", Arg.Any()) + .Returns(new SubmodelDescriptor { Id = "Nameplate" }); + + var result = await _sut.GetAllSubmodelDescriptorsAsync(5, null, CancellationToken.None); + + Assert.Single(result.Result!); + Assert.Equal("Nameplate", result.Result![0].Id); + } + + [Fact] + public async Task GetAllSubmodelDescriptorsAsync_WhenLimitReachedAtAasBoundary_CursorAasIdIsConsumedAas() + { + var shell1 = CreateShellWithMultipleSubmodels("shell-1", "sm-1", "sm-2", "sm-3"); + var shell2 = CreateShellWithMultipleSubmodels("shell-2", "sm-4", "sm-5"); + + var shells = new Shells { Result = [shell1, shell2] }; + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(shells); + + _submodelTemplateMappingProvider.GetTemplateId(Arg.Any()).Returns(x => (string)x[0]); + _provider.GetDataForSubmodelDescriptorByIdAsync(Arg.Any(), Arg.Any()) + .Returns(x => new SubmodelDescriptor { Id = (string)x[0] }); + + var result = await _sut.GetAllSubmodelDescriptorsAsync(5, null, CancellationToken.None); + + Assert.Equal(5, result.Result!.Count); + Assert.NotNull(result.PagingMetaData?.Cursor); + + var decoded = SubmodelPaginationCursor.Decode(result.PagingMetaData!.Cursor!); + Assert.Equal("sm-5", decoded!.SubmodelId); + Assert.Equal("shell-2", decoded.AasId); + } + + [Fact] + public async Task GetAllSubmodelDescriptorsAsync_WhenLimitReachedAtFirstAasBoundary_CursorAasIdIsConsumedAas() + { + var shell1 = CreateShellWithMultipleSubmodels("shell-1", "sm-1", "sm-2"); + + var shells = new Shells + { + Result = [shell1], + PagingMetaData = new PagingMetaData { Cursor = "next-page-token" } + }; + _aasRepositoryService.GetShellsByFiltersAsync(null, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(shells); + + _submodelTemplateMappingProvider.GetTemplateId(Arg.Any()).Returns(x => (string)x[0]); + _provider.GetDataForSubmodelDescriptorByIdAsync(Arg.Any(), Arg.Any()) + .Returns(x => new SubmodelDescriptor { Id = (string)x[0] }); + + var result = await _sut.GetAllSubmodelDescriptorsAsync(2, null, CancellationToken.None); + + Assert.Equal(2, result.Result!.Count); + Assert.NotNull(result.PagingMetaData?.Cursor); + + var decoded = SubmodelPaginationCursor.Decode(result.PagingMetaData!.Cursor!); + Assert.Equal("sm-2", decoded!.SubmodelId); + Assert.Equal("shell-1", decoded.AasId); + } + + private static AssetAdministrationShell CreateShell(string submodelId, string shellId = "shell-id") { return new AssetAdministrationShell( - id: "shell-id", + id: shellId, assetInformation: new AssetInformation(assetKind: AssetKind.Instance, globalAssetId: null), submodels: [ @@ -290,4 +404,17 @@ private static AssetAdministrationShell CreateShell(string submodelId) referredSemanticId: null) ]); } + + private static AssetAdministrationShell CreateShellWithMultipleSubmodels(string shellId, params string[] submodelIds) + { + var refs = submodelIds.Select(id => (IReference)new Reference( + type: ReferenceTypes.ModelReference, + keys: [new Key(KeyTypes.Submodel, id)], + referredSemanticId: null)).ToList(); + + return new AssetAdministrationShell( + id: shellId, + assetInformation: new AssetInformation(assetKind: AssetKind.Instance, globalAssetId: null), + submodels: refs); + } } diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryServiceTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryServiceTests.cs index 1653bad4..3fc8f7d7 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryServiceTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryServiceTests.cs @@ -32,9 +32,8 @@ public class SubmodelRepositoryServiceTests private readonly IPluginDataHandler _pluginDataHandler = Substitute.For(); private readonly IPluginManifestConflictHandler _pluginManifestConflictHandler = Substitute.For(); private readonly IAasRepositoryTemplateService _aasRepositoryTemplateService = Substitute.For(); - private readonly IFileContentProvider _fileAttachmentStreamProvider = Substitute.For(); + private readonly IFileContentProvider _fileContentProvider = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); - private readonly IOptions _templateManagementOptions; private readonly SubmodelRepositoryService _sut; private const string SubmodelId = "NameplateSubmodel"; @@ -42,7 +41,7 @@ public class SubmodelRepositoryServiceTests public SubmodelRepositoryServiceTests() { - _templateManagementOptions = Options.Create(new TemplateManagementConfig + var templateManagementOptions = Options.Create(new TemplateManagementConfig { SubmodelTemplateRepository = new ServiceInstance { @@ -54,36 +53,21 @@ public SubmodelRepositoryServiceTests() _logger, _templateService, _aasRepositoryTemplateService, - _templateManagementOptions, + templateManagementOptions, _semanticIdHandler, _pluginDataHandler, _pluginManifestConflictHandler, - _fileAttachmentStreamProvider, + _fileContentProvider, Options.Create(new GeneralConfig { MaxFileAttachmentSizeBytes = 30 * 1024 * 1024 })); } + #region GetSubmodelAsync + [Fact] public async Task GetSubmodelAsync_ReturnsFilledSubmodel() { - var semanticId = TestData.CreateSubmodelTreeNode(); - var values = TestData.CreateSubmodelTreeNode(); var expected = TestData.CreateFilledSubmodel(); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, (string?)null, Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); - _semanticIdHandler.Extract(Arg.Any()).Returns(semanticId); - - _pluginDataHandler - .TryGetValuesAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(Task.FromResult(values)); - - _semanticIdHandler.FillOutTemplate(Arg.Any(), values) - .Returns(expected); + ArrangeSubmodelBuild(SubmodelId, expected); var result = await _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None); @@ -94,34 +78,87 @@ public async Task GetSubmodelAsync_ReturnsFilledSubmodel() public async Task GetSubmodelAsync_WhenQueryOptionsProvided_PassesThemToTemplateService() { var queryOptions = new SubmodelQueryOptions("deep", "withBlobValue"); - var template = TestData.CreateSubmodel(); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), queryOptions, Arg.Any()) - .Returns(template); - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(TestData.CreateFilledSubmodel()); + ArrangeSubmodelBuild(SubmodelId, TestData.CreateFilledSubmodel()); await _sut.GetSubmodelAsync(SubmodelId, queryOptions, CancellationToken.None); await _templateService.Received(1) - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), queryOptions, Arg.Any()); + .GetFilteredSubmodelTemplateAsync(SubmodelId, queryOptions, Arg.Any()); } [Fact] public async Task GetSubmodelAsync_WhenTemplateReturnsNull_ThrowsSubmodelNotFoundException() { _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any(), Arg.Any()) + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) .Returns((ISubmodel?)null); await Assert.ThrowsAsync(() => _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); } + [Fact] + public async Task GetSubmodelAsync_WhenResourceNotFound_ThrowsSubmodelNotFoundException() + { + _templateService + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) + .ThrowsAsync(new ResourceNotFoundException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelAsync_WhenResponseParsingFails_ThrowsInternalDataProcessingException() + { + ArrangeSubmodelBuild_PluginThrows(new ResponseParsingException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelAsync_WhenRequestTimesOut_ThrowsPluginNotAvailableException() + { + ArrangeSubmodelBuild_PluginThrows(new RequestTimeoutException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelAsync_WhenUnauthorized_ThrowsServiceUnAuthorizedException() + { + ArrangeSubmodelBuild_PluginThrows(new AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure.UnauthorizedAccessException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelAsync_WhenMultiPluginConflict_ThrowsInternalDataProcessingException() + { + ArrangeSubmodelBuild_PluginThrows(new MultiPluginConflictException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelAsync_SetsSubmodelIdOnResult() + { + var filledSubmodel = TestData.CreateFilledSubmodel(); + ArrangeSubmodelBuild(SubmodelId, filledSubmodel); + + var result = await _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None); + + Assert.Equal(SubmodelId, result.Id); + } + + #endregion + + #region GetSubmodelElementAsync + [Fact] public async Task GetSubmodelElementAsync_ReturnsFilledSubmodelElement() { @@ -133,13 +170,7 @@ public async Task GetSubmodelElementAsync_ReturnsFilledSubmodelElement() .GetSubmodelTemplateAsync(SubmodelId, IdShortPath, Arg.Any(), Arg.Any()) .Returns(submodel); - var semanticTree = CreateSubmodelTreeNode(""); - _semanticIdHandler.Extract(submodel).Returns(semanticTree); - - _pluginDataHandler.TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()).Returns(CreateSubmodelTreeNode("Test John Doe")); - - _semanticIdHandler.FillOutTemplate(submodel, Arg.Any()) - .Returns(filledSubmodel); + ArrangeSemanticPipeline(submodel, filledSubmodel); _semanticIdHandler.Extract(filledSubmodel, IdShortPath).Returns(expected); var result = await _sut.GetSubmodelElementAsync(SubmodelId, IdShortPath, null, CancellationToken.None) as SubmodelElementCollection; @@ -160,13 +191,7 @@ public async Task GetSubmodelElementAsync_IdShortWithIndex_ReturnsFilledSubmodel .GetSubmodelTemplateAsync(SubmodelId, IdShortPathWithNestedElement, Arg.Any(), Arg.Any()) .Returns(submodel); - var semanticTree = CreateSubmodelTreeNode(""); - _semanticIdHandler.Extract(submodel).Returns(semanticTree); - - _pluginDataHandler.TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()).Returns(CreateSubmodelTreeNode("Test John Doe")); - - _semanticIdHandler.FillOutTemplate(submodel, Arg.Any()) - .Returns(filledSubmodel); + ArrangeSemanticPipeline(submodel, filledSubmodel); _semanticIdHandler.Extract(filledSubmodel, IdShortPathWithNestedElement).Returns(expected); var result = await _sut.GetSubmodelElementAsync(SubmodelId, IdShortPathWithNestedElement, null, CancellationToken.None) as Property; @@ -175,39 +200,6 @@ public async Task GetSubmodelElementAsync_IdShortWithIndex_ReturnsFilledSubmodel Assert.Equal(expected.Value, result?.Value); } - [Fact] - public async Task GetSubmodelAsync_WhenResourceNotFound_ThrowsPluginRequestFailedException() - { - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any(), Arg.Any()) - .ThrowsAsync(new ResourceNotFoundException()); - - await Assert.ThrowsAsync(() => - _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); - } - - [Fact] - public async Task GetSubmodelAsync_WhenResponseParsingFails_ThrowsInternalDataProcessingException() - { - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) - .ThrowsAsync(new ResponseParsingException()); - - await Assert.ThrowsAsync(() => - _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); - } - - [Fact] - public async Task GetSubmodelAsync_WhenRequestTimesOut_ThrowsPluginNotAvailableException() - { - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) - .ThrowsAsync(new RequestTimeoutException()); - - await Assert.ThrowsAsync(() => - _sut.GetSubmodelAsync(SubmodelId, null, CancellationToken.None)); - } - [Fact] public async Task GetSubmodelElementAsync_WhenResourceNotFound_ThrowsSubmodelElementNotFoundException() { @@ -222,6 +214,10 @@ await Assert.ThrowsAsync(() => [Fact] public async Task GetSubmodelElementAsync_WhenResponseParsingFails_ThrowsInternalDataProcessingException() { + _templateService + .GetSubmodelTemplateAsync(SubmodelId, IdShortPath, Arg.Any(), Arg.Any()) + .Returns(TestData.CreateSubmodel()); + _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); _pluginDataHandler .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) .ThrowsAsync(new ResponseParsingException()); @@ -233,6 +229,10 @@ await Assert.ThrowsAsync(() => [Fact] public async Task GetSubmodelElementAsync_WhenRequestTimesOut_ThrowsPluginNotAvailableException() { + _templateService + .GetSubmodelTemplateAsync(SubmodelId, IdShortPath, Arg.Any(), Arg.Any()) + .Returns(TestData.CreateSubmodel()); + _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); _pluginDataHandler .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) .ThrowsAsync(new RequestTimeoutException()); @@ -241,21 +241,15 @@ await Assert.ThrowsAsync(() => _sut.GetSubmodelElementAsync(SubmodelId, IdShortPath, null, CancellationToken.None)); } - public static SemanticBranchNode CreateSubmodelTreeNode(string value) - { - var submodel = new SemanticBranchNode("http://example.com/idta/digital-nameplate/semantic-id", Cardinality.Unknown); - var contactInformation = new SemanticBranchNode("http://example.com/idta/digital-nameplate/contact-information", Cardinality.ZeroToMany); - var contactName = new SemanticLeafNode("http://example.com/idta/digital-nameplate/contact-name", value, DataType.String, Cardinality.One); - submodel.AddChild(contactInformation); - contactInformation.AddChild(contactName); - return submodel; - } + #endregion + + #region GetAllSubmodelsAsync [Fact] public async Task GetAllSubmodelsAsync_ReturnsEmpty_WhenNoShellsFound() { _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [] }); var result = await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); @@ -268,66 +262,23 @@ public async Task GetAllSubmodelsAsync_BuildsSubmodelForEachSubmodelId() { const string ShellId = "https://example.com/shells/001"; const string SubmodelId1 = "https://example.com/submodels/Nameplate"; - var filledSubmodel = TestData.CreateFilledSubmodel(); - var submodelRef = new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, SubmodelId1)]); - - _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [new ShellDescriptorMetaData { Id = ShellId }] }); - - _aasRepositoryTemplateService - .GetSubmodelRefByIdAsync(ShellId, Arg.Any()) - .Returns([submodelRef]); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId1, Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); - - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + ArrangeShellsResponse([new ShellDescriptorMetaData { Id = ShellId }]); + ArrangeSubmodelRefsForShell(ShellId, [SubmodelId1]); + ArrangeValidateSemanticIdFilter(SubmodelId1, true); + ArrangeSubmodelBuild(SubmodelId1, TestData.CreateFilledSubmodel()); var result = await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); Assert.Single(result.Result); } - [Fact] - public async Task GetAllSubmodelsAsync_SkipsSubmodel_WhenFilteredTemplateReturnsNull() - { - const string ShellId = "https://example.com/shells/001"; - const string SubmodelId1 = "https://example.com/submodels/Nameplate"; - var submodelRef = new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, SubmodelId1)]); - - _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [new ShellDescriptorMetaData { Id = ShellId }] }); - - _aasRepositoryTemplateService - .GetSubmodelRefByIdAsync(ShellId, Arg.Any()) - .Returns([submodelRef]); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId1, Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ISubmodel?)null); - - var result = await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); - - Assert.Empty(result.Result); - } - [Fact] public async Task GetAllSubmodelsAsync_SkipsShell_WhenSubmodelRefsFails() { const string ShellId = "https://example.com/shells/001"; - _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [new ShellDescriptorMetaData { Id = ShellId }] }); - + ArrangeShellsResponse([new ShellDescriptorMetaData { Id = ShellId }]); _aasRepositoryTemplateService .GetSubmodelRefByIdAsync(ShellId, Arg.Any()) .ThrowsAsync(new ResourceNotFoundException()); @@ -337,53 +288,18 @@ public async Task GetAllSubmodelsAsync_SkipsShell_WhenSubmodelRefsFails() Assert.Empty(result.Result); } - [Fact] - public async Task GetAllSubmodelsAsync_DeduplicatesSubmodelIds_AcrossShells() - { - const string SubmodelId1 = "https://example.com/submodels/Shared"; - var submodelRef = new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, SubmodelId1)]); - var filledSubmodel = TestData.CreateFilledSubmodel(); - - _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(new ShellDescriptorsMetaData - { - ShellDescriptors = - [ - new ShellDescriptorMetaData { Id = "https://example.com/shells/001" }, - new ShellDescriptorMetaData { Id = "https://example.com/shells/002" } - ] - }); - - _aasRepositoryTemplateService - .GetSubmodelRefByIdAsync(Arg.Any(), Arg.Any()) - .Returns([submodelRef]); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId1, Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); - - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); - - var result = await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); - - Assert.Single(result.Result); - } - [Fact] public async Task GetAllSubmodelsAsync_FiltersShellsByIdShort_WhenIdShortProvided() { - const string IdShort = "M&M01"; + const string IdShort = "MM01"; var filter = new SubmodelSearchFilter { IdShort = IdShort }; _pluginDataHandler .GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Is(f => f != null && f.IdShort == IdShort), + Arg.Any(), + Arg.Any(), Arg.Any()) .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [] }); @@ -393,6 +309,8 @@ await _pluginDataHandler.Received(1) .GetDataForShellsByAssetIdsAsync( Arg.Any>(), Arg.Is(f => f != null && f.IdShort == IdShort), + Arg.Any(), + Arg.Any(), Arg.Any()); } @@ -404,36 +322,20 @@ public async Task GetAllSubmodelsAsync_WhenSemanticIdFilterProvided_LooksUpFilte const string ShellId = "https://example.com/shells/001"; const string SubmodelId1 = "https://example.com/submodels/Nameplate"; var filter = new SubmodelSearchFilter { SemanticId = SemanticId }; - var submodelRef = new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, SubmodelId1)]); - var filledSubmodel = TestData.CreateFilledSubmodel(); - - _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [new ShellDescriptorMetaData { Id = ShellId }] }); _templateService .GetFilteredSubmodelTemplateIdAsync(SemanticId, Arg.Any()) .Returns(FilteredTemplateId); - _aasRepositoryTemplateService - .GetSubmodelRefByIdAsync(ShellId, Arg.Any()) - .Returns([submodelRef]); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId1, FilteredTemplateId, Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); - - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + ArrangeShellsResponse([new ShellDescriptorMetaData { Id = ShellId }]); + ArrangeSubmodelRefsForShell(ShellId, [SubmodelId1]); + _templateService.ValidateSemanticIdFilter(SubmodelId1, FilteredTemplateId).Returns(true); + ArrangeSubmodelBuild(SubmodelId1, TestData.CreateFilledSubmodel()); var result = await _sut.GetAllSubmodelsAsync(filter, null, null, null, CancellationToken.None); Assert.Single(result.Result); await _templateService.Received(1).GetFilteredSubmodelTemplateIdAsync(SemanticId, Arg.Any()); - await _templateService.Received(1).GetFilteredSubmodelTemplateAsync(SubmodelId1, FilteredTemplateId, Arg.Any(), Arg.Any()); } [Fact] @@ -442,11 +344,6 @@ public async Task GetAllSubmodelsAsync_WhenSemanticIdNotFound_ThrowsSubmodelNotF const string SemanticId = "https://example.com/unknown-semantic-id"; var filter = new SubmodelSearchFilter { SemanticId = SemanticId }; - _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(new ShellDescriptorsMetaData { ShellDescriptors = [new ShellDescriptorMetaData { Id = "https://example.com/shells/001" }] }); - - // GetFilteredSubmodelTemplateIdAsync returns null — semantic ID not found in any template _templateService .GetFilteredSubmodelTemplateIdAsync(SemanticId, Arg.Any()) .Returns((string?)null); @@ -455,62 +352,197 @@ await Assert.ThrowsAsync(() => _sut.GetAllSubmodelsAsync(filter, null, null, null, CancellationToken.None)); } + [Fact] + public async Task GetAllSubmodelsAsync_FiltersSubmodels_WhenValidateSemanticIdFilterReturnsFalse() + { + const string SemanticId = "https://example.com/semanticId"; + const string FilteredTemplateId = "Nameplate"; + const string ShellId = "https://example.com/shells/001"; + const string SubmodelId1 = "https://example.com/submodels/sm-1"; + const string SubmodelId2 = "https://example.com/submodels/sm-2"; + var filter = new SubmodelSearchFilter { SemanticId = SemanticId }; + + _templateService.GetFilteredSubmodelTemplateIdAsync(SemanticId, Arg.Any()).Returns(FilteredTemplateId); + ArrangeShellsResponse([new ShellDescriptorMetaData { Id = ShellId }]); + ArrangeSubmodelRefsForShell(ShellId, [SubmodelId1, SubmodelId2]); + _templateService.ValidateSemanticIdFilter(SubmodelId1, FilteredTemplateId).Returns(true); + _templateService.ValidateSemanticIdFilter(SubmodelId2, FilteredTemplateId).Returns(false); + ArrangeSubmodelBuild(SubmodelId1, TestData.CreateFilledSubmodel()); + + var result = await _sut.GetAllSubmodelsAsync(filter, null, null, null, CancellationToken.None); + + Assert.Single(result.Result); + } + [Fact] public async Task GetAllSubmodelsAsync_SkipsShellsWithEmptyOrWhitespaceId() { const string ValidShellId = "https://example.com/shells/valid"; const string SubmodelId1 = "https://example.com/submodels/Nameplate"; - var submodelRef = new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, SubmodelId1)]); - var filledSubmodel = TestData.CreateFilledSubmodel(); + + ArrangeShellsResponse([ + new ShellDescriptorMetaData { Id = string.Empty }, + new ShellDescriptorMetaData { Id = " " }, + new ShellDescriptorMetaData { Id = ValidShellId } + ]); + ArrangeSubmodelRefsForShell(ValidShellId, [SubmodelId1]); + ArrangeValidateSemanticIdFilter(SubmodelId1, true); + ArrangeSubmodelBuild(SubmodelId1, TestData.CreateFilledSubmodel()); + + var result = await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); + + Assert.Single(result.Result); + await _aasRepositoryTemplateService.Received(1).GetSubmodelRefByIdAsync(ValidShellId, Arg.Any()); + } + + [Fact] + public async Task GetAllSubmodelsAsync_WhenLimitReachedAtAasBoundary_CursorContainsLastSubmodelAndConsumedAas() + { + const string Shell1Id = "https://example.com/shells/aas-1"; + const string Shell2Id = "https://example.com/shells/aas-2"; + const string Sm1 = "https://example.com/submodels/sm-1"; + const string Sm2 = "https://example.com/submodels/sm-2"; + const string Sm3 = "https://example.com/submodels/sm-3"; + const string Sm4 = "https://example.com/submodels/sm-4"; + const string Sm5 = "https://example.com/submodels/sm-5"; + + ArrangeShellsResponse([ + new ShellDescriptorMetaData { Id = Shell1Id }, + new ShellDescriptorMetaData { Id = Shell2Id } + ]); + ArrangeSubmodelRefsForShell(Shell1Id, [Sm1, Sm2, Sm3]); + ArrangeSubmodelRefsForShell(Shell2Id, [Sm4, Sm5]); + ArrangeValidateSemanticIdFilterForAll(true); + ArrangeSubmodelBuildForAny(TestData.CreateFilledSubmodel()); + + var result = await _sut.GetAllSubmodelsAsync(null, null, limit: 5, null, CancellationToken.None); + + Assert.Equal(5, result.Result.Count); + Assert.NotNull(result.PagingMetaData?.Cursor); + + var decoded = SubmodelPaginationCursor.Decode(result.PagingMetaData!.Cursor!); + Assert.Equal(Sm5, decoded!.SubmodelId); + Assert.Equal(Shell2Id, decoded.AasId); + } + + [Fact] + public async Task GetAllSubmodelsAsync_WhenLimitReachedMidAas_CursorAasIdIsCurrentShell() + { + const string Shell1Id = "https://example.com/shells/aas-1"; + const string Sm1 = "https://example.com/submodels/sm-1"; + const string Sm2 = "https://example.com/submodels/sm-2"; + const string Sm3 = "https://example.com/submodels/sm-3"; _pluginDataHandler - .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ShellDescriptorsMetaData { - ShellDescriptors = - [ - new ShellDescriptorMetaData { Id = string.Empty }, // should be skipped - new ShellDescriptorMetaData { Id = " " }, // should be skipped - new ShellDescriptorMetaData { Id = ValidShellId } // should be included - ] + ShellDescriptors = [new ShellDescriptorMetaData { Id = Shell1Id }], + PagingMetaData = new PagingMetaData { Cursor = "next-page-token" } }); + ArrangeSubmodelRefsForShell(Shell1Id, [Sm1, Sm2, Sm3]); + ArrangeValidateSemanticIdFilterForAll(true); + ArrangeSubmodelBuildForAny(TestData.CreateFilledSubmodel()); - _aasRepositoryTemplateService - .GetSubmodelRefByIdAsync(ValidShellId, Arg.Any()) - .Returns([submodelRef]); + var result = await _sut.GetAllSubmodelsAsync(null, null, limit: 2, null, CancellationToken.None); - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId1, Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); + Assert.Equal(2, result.Result.Count); + Assert.NotNull(result.PagingMetaData?.Cursor); + + var decoded = SubmodelPaginationCursor.Decode(result.PagingMetaData!.Cursor!); + Assert.Equal(Sm2, decoded!.SubmodelId); + } + + [Fact] + public async Task GetAllSubmodelsAsync_WhenLimitReachedAtExactAasBoundary_CursorAasIdIsConsumedAas() + { + const string Shell1Id = "https://example.com/shells/aas-1"; + const string Sm1 = "https://example.com/submodels/sm-1"; + const string Sm2 = "https://example.com/submodels/sm-2"; - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ShellDescriptorsMetaData + { + ShellDescriptors = [new ShellDescriptorMetaData { Id = Shell1Id }], + PagingMetaData = new PagingMetaData { Cursor = "next-page-token" } + }); + ArrangeSubmodelRefsForShell(Shell1Id, [Sm1, Sm2]); + ArrangeValidateSemanticIdFilterForAll(true); + ArrangeSubmodelBuildForAny(TestData.CreateFilledSubmodel()); + + var result = await _sut.GetAllSubmodelsAsync(null, null, limit: 2, null, CancellationToken.None); + + Assert.Equal(2, result.Result.Count); + Assert.NotNull(result.PagingMetaData?.Cursor); + + var decoded = SubmodelPaginationCursor.Decode(result.PagingMetaData!.Cursor!); + Assert.Equal(Sm2, decoded!.SubmodelId); + Assert.Equal(Shell1Id, decoded.AasId); + } + + [Fact] + public async Task GetAllSubmodelsAsync_NoCursor_WhenAllResultsFitInPage() + { + const string ShellId = "https://example.com/shells/001"; + const string SubmodelId1 = "https://example.com/submodels/sm-1"; + + ArrangeShellsResponse([new ShellDescriptorMetaData { Id = ShellId }]); + ArrangeSubmodelRefsForShell(ShellId, [SubmodelId1]); + ArrangeValidateSemanticIdFilter(SubmodelId1, true); + ArrangeSubmodelBuild(SubmodelId1, TestData.CreateFilledSubmodel()); + + var result = await _sut.GetAllSubmodelsAsync(null, null, limit: 100, null, CancellationToken.None); + + Assert.Single(result.Result); + Assert.Null(result.PagingMetaData?.Cursor); + } + + [Fact] + public async Task GetAllSubmodelsAsync_DefaultsTo100_WhenLimitIsNull() + { + ArrangeShellsResponse([]); + + await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); + + await _pluginDataHandler.Received(1).GetDataForShellsByAssetIdsAsync( + Arg.Any>(), + Arg.Any(), + 100, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetAllSubmodelsAsync_SkipsSubmodelRefsWithNullOrEmptyValues() + { + const string ShellId = "https://example.com/shells/001"; + const string ValidSubmodelId = "https://example.com/submodels/valid"; + + ArrangeShellsResponse([new ShellDescriptorMetaData { Id = ShellId }]); + _aasRepositoryTemplateService.GetSubmodelRefByIdAsync(ShellId, Arg.Any()) + .Returns(new List + { + new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, "")]), + new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, ValidSubmodelId)]) + }); + ArrangeValidateSemanticIdFilter(ValidSubmodelId, true); + ArrangeSubmodelBuild(ValidSubmodelId, TestData.CreateFilledSubmodel()); var result = await _sut.GetAllSubmodelsAsync(null, null, null, null, CancellationToken.None); Assert.Single(result.Result); - // Only the valid shell should have been queried for submodel refs - await _aasRepositoryTemplateService.Received(1).GetSubmodelRefByIdAsync(ValidShellId, Arg.Any()); - await _aasRepositoryTemplateService.DidNotReceive().GetSubmodelRefByIdAsync(null!, Arg.Any()); } + #endregion + + #region GetAllSubmodelElementsAsync + [Fact] public async Task GetAllSubmodelElementsAsync_ReturnsAllElements_WhenSubmodelExists() { var filledSubmodel = TestData.CreateFilledSubmodel(); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); - - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + ArrangeSubmodelBuild(SubmodelId, filledSubmodel); var result = await _sut.GetAllSubmodelElementsAsync(SubmodelId, null, null, null, CancellationToken.None); @@ -528,9 +560,8 @@ public async Task GetAllSubmodelElementsAsync_ReturnsEmptyList_WhenSubmodelHasNo submodelElements: []); _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any(), Arg.Any()) + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) .Returns(emptySubmodel); - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); _pluginDataHandler .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) @@ -547,16 +578,7 @@ public async Task GetAllSubmodelElementsAsync_ReturnsEmptyList_WhenSubmodelHasNo public async Task GetAllSubmodelElementsAsync_ReturnsPagedResult_WhenLimitApplied() { var filledSubmodel = TestData.CreateFilledSubmodel(); - - _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TestData.CreateSubmodel()); - - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler - .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) - .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + ArrangeSubmodelBuild(SubmodelId, filledSubmodel); var result = await _sut.GetAllSubmodelElementsAsync(SubmodelId, null, limit: 2, cursor: null, CancellationToken.None); @@ -565,10 +587,21 @@ public async Task GetAllSubmodelElementsAsync_ReturnsPagedResult_WhenLimitApplie } [Fact] - public async Task GetAllSubmodelElementsAsync_WhenSubmodelNotFound_ThrowsSubmodelElementNotFoundException() + public async Task GetAllSubmodelElementsAsync_WhenTemplateNull_ThrowsSubmodelElementNotFoundException() { _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, (string?)null, Arg.Any(), Arg.Any()) + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) + .Returns((ISubmodel?)null); + + await Assert.ThrowsAsync(() => + _sut.GetAllSubmodelElementsAsync(SubmodelId, null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task GetAllSubmodelElementsAsync_WhenResourceNotFound_ThrowsSubmodelElementNotFoundException() + { + _templateService + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) .ThrowsAsync(new ResourceNotFoundException()); await Assert.ThrowsAsync(() => @@ -579,9 +612,8 @@ await Assert.ThrowsAsync(() => public async Task GetAllSubmodelElementsAsync_WhenResponseParsingFails_ThrowsInternalDataProcessingException() { _templateService - .GetFilteredSubmodelTemplateAsync(SubmodelId, (string?)null, Arg.Any(), Arg.Any()) + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) .Returns(TestData.CreateSubmodel()); - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); _pluginDataHandler .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) @@ -591,31 +623,25 @@ await Assert.ThrowsAsync(() => _sut.GetAllSubmodelElementsAsync(SubmodelId, null, null, null, CancellationToken.None)); } - private void ArrangeAttachmentElement(string idShortPath, ISubmodelElement element) - { - var template = TestData.CreateSubmodelWithElement(element, idShortPath); - _templateService.GetSubmodelTemplateAsync(SubmodelId, idShortPath, Arg.Any(), Arg.Any()).Returns(template); - _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _pluginDataHandler.TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()).Returns(CreateSubmodelTreeNode("")); - _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(template); - _semanticIdHandler.Extract(Arg.Any(), idShortPath).Returns(element); - } + #endregion + + #region GetFileAttachmentAsync [Fact] public async Task GetFileAttachmentAsync_WhenElementIsFileWithHttpUrl_ReturnsStreamWithCorrectMetadata() { - const string IdShortPath = "Documents.ProductImage"; + const string fileIdShortPath = "Documents.ProductImage"; const string FileUrl = "https://fake-plugin.local/files/product.png"; const string FileContent = "binary-file-data"; var fileElement = new AasCore.Aas3_1.File(contentType: "image/png") { Value = FileUrl, IdShort = "ProductImage" }; - ArrangeAttachmentElement(IdShortPath, fileElement); + ArrangeAttachmentElement(fileIdShortPath, fileElement); var stream = new MemoryStream(Encoding.UTF8.GetBytes(FileContent)); var fileContentResponse = new FileContentResponse(stream); - _fileAttachmentStreamProvider.GetFileContentAsync(FileUrl, Arg.Any()).Returns(fileContentResponse); + _fileContentProvider.GetFileContentAsync(FileUrl, Arg.Any()).Returns(fileContentResponse); - var result = await _sut.GetFileAttachmentAsync(SubmodelId, IdShortPath, CancellationToken.None); + var result = await _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None); await using (result.Content) { var body = await new StreamReader(result.Content).ReadToEndAsync(); @@ -624,54 +650,226 @@ public async Task GetFileAttachmentAsync_WhenElementIsFileWithHttpUrl_ReturnsStr Assert.Contains("image/png", result.ContentType); } - await _fileAttachmentStreamProvider.Received(1).GetFileContentAsync(FileUrl, Arg.Any()); + await _fileContentProvider.Received(1).GetFileContentAsync(FileUrl, Arg.Any()); + } + + [Fact] + public async Task GetFileAttachmentAsync_WhenContentTypeIsEmpty_DefaultsToOctetStream() + { + const string fileIdShortPath = "Documents.ProductImage"; + const string FileUrl = "https://fake-plugin.local/files/data.bin"; + + var fileElement = new AasCore.Aas3_1.File(contentType: "") { Value = FileUrl, IdShort = "ProductImage" }; + ArrangeAttachmentElement(fileIdShortPath, fileElement); + + var stream = new MemoryStream([0x01, 0x02]); + _fileContentProvider.GetFileContentAsync(FileUrl, Arg.Any()).Returns(new FileContentResponse(stream)); + + var result = await _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None); + await using (result.Content) + { + Assert.Equal("application/octet-stream", result.ContentType); + } } [Fact] public async Task GetFileAttachmentAsync_WhenElementIsNotFile_ThrowsInvalidUserInputException() { - const string IdShortPath = "ManufacturerName"; + const string fileIdShortPath = "ManufacturerName"; var property = new Property(DataTypeDefXsd.String) { IdShort = "ManufacturerName" }; - ArrangeAttachmentElement(IdShortPath, property); - var ex = await Assert.ThrowsAsync(() => - _sut.GetFileAttachmentAsync(SubmodelId, IdShortPath, CancellationToken.None)); - Assert.Equal("Invalid User Input.", ex.Message); + ArrangeAttachmentElement(fileIdShortPath, property); + + await Assert.ThrowsAsync(() => + _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None)); } [Fact] public async Task GetFileAttachmentAsync_WhenSubmodelNotFound_ThrowsSubmodelElementNotFoundException() { - const string IdShortPath = "Documents.ProductImage"; + const string fileIdShortPath = "Documents.ProductImage"; _templateService - .GetSubmodelTemplateAsync(SubmodelId, IdShortPath, Arg.Any(), Arg.Any()) + .GetSubmodelTemplateAsync(SubmodelId, fileIdShortPath, Arg.Any(), Arg.Any()) .ThrowsAsync(new ResourceNotFoundException()); await Assert.ThrowsAsync(() => - _sut.GetFileAttachmentAsync(SubmodelId, IdShortPath, CancellationToken.None)); + _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None)); } [Fact] public async Task GetFileAttachmentAsync_WhenFileValueIsEmpty_ThrowsSubmodelElementNotFoundException() { - const string IdShortPath = "Documents.ProductImage"; + const string fileIdShortPath = "Documents.ProductImage"; var fileElement = new AasCore.Aas3_1.File(contentType: "image/png") { Value = "", IdShort = "ProductImage" }; - ArrangeAttachmentElement(IdShortPath, fileElement); + ArrangeAttachmentElement(fileIdShortPath, fileElement); + + await Assert.ThrowsAsync(() => + _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None)); + } + + [Fact] + public async Task GetFileAttachmentAsync_WhenFileValueIsNull_ThrowsSubmodelElementNotFoundException() + { + const string fileIdShortPath = "Documents.ProductImage"; + var fileElement = new AasCore.Aas3_1.File(contentType: "image/png") { Value = null, IdShort = "ProductImage" }; + ArrangeAttachmentElement(fileIdShortPath, fileElement); await Assert.ThrowsAsync(() => - _sut.GetFileAttachmentAsync(SubmodelId, IdShortPath, CancellationToken.None)); + _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None)); } [Fact] public async Task GetFileAttachmentAsync_WhenFileUrlIsNotHttpOrHttps_ThrowsInternalDataProcessingException() { - const string IdShortPath = "Documents.ProductImage"; + const string fileIdShortPath = "Documents.ProductImage"; const string FileUrl = "ftp://fake-plugin.local/files/product.png"; + var fileElement = new AasCore.Aas3_1.File(contentType: "image/png") { Value = FileUrl, IdShort = "ProductImage" }; + ArrangeAttachmentElement(fileIdShortPath, fileElement); + + await Assert.ThrowsAsync(() => + _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None)); + } + [Fact] + public async Task GetFileAttachmentAsync_WhenFileUrlIsRelative_ThrowsInternalDataProcessingException() + { + const string fileIdShortPath = "Documents.ProductImage"; + const string FileUrl = "/relative/path/file.png"; var fileElement = new AasCore.Aas3_1.File(contentType: "image/png") { Value = FileUrl, IdShort = "ProductImage" }; - ArrangeAttachmentElement(IdShortPath, fileElement); + ArrangeAttachmentElement(fileIdShortPath, fileElement); await Assert.ThrowsAsync(() => - _sut.GetFileAttachmentAsync(SubmodelId, IdShortPath, CancellationToken.None)); + _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None)); } + + [Fact] + public async Task GetFileAttachmentAsync_ExtractsFileNameFromUrl() + { + const string fileIdShortPath = "Documents.ProductImage"; + const string FileUrl = "https://fake-plugin.local/files/my-document.pdf"; + + var fileElement = new AasCore.Aas3_1.File(contentType: "application/pdf") { Value = FileUrl, IdShort = "ProductImage" }; + ArrangeAttachmentElement(fileIdShortPath, fileElement); + + var stream = new MemoryStream([0x01]); + _fileContentProvider.GetFileContentAsync(FileUrl, Arg.Any()).Returns(new FileContentResponse(stream)); + + var result = await _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None); + await using (result.Content) + { + Assert.Equal("my-document.pdf", result.FileName); + } + } + + [Fact] + public async Task GetFileAttachmentAsync_UsesIdShortAsFileName_WhenUrlHasNoPath() + { + const string fileIdShortPath = "Documents.ProductImage"; + const string FileUrl = "https://fake-plugin.local/"; + + var fileElement = new AasCore.Aas3_1.File(contentType: "image/png") { Value = FileUrl, IdShort = "ProductImage" }; + ArrangeAttachmentElement(fileIdShortPath, fileElement); + + var stream = new MemoryStream([0x01]); + _fileContentProvider.GetFileContentAsync(FileUrl, Arg.Any()).Returns(new FileContentResponse(stream)); + + var result = await _sut.GetFileAttachmentAsync(SubmodelId, fileIdShortPath, CancellationToken.None); + await using (result.Content) + { + Assert.Equal("ProductImage", result.FileName); + } + } + + #endregion + + #region Helpers + + public static SemanticBranchNode CreateSubmodelTreeNode(string value) + { + var submodel = new SemanticBranchNode("http://example.com/idta/digital-nameplate/semantic-id", Cardinality.Unknown); + var contactInformation = new SemanticBranchNode("http://example.com/idta/digital-nameplate/contact-information", Cardinality.ZeroToMany); + var contactName = new SemanticLeafNode("http://example.com/idta/digital-nameplate/contact-name", value, DataType.String, Cardinality.One); + submodel.AddChild(contactInformation); + contactInformation.AddChild(contactName); + return submodel; + } + + private void ArrangeSubmodelBuild(string submodelId, Submodel filledSubmodel) + { + _templateService + .GetFilteredSubmodelTemplateAsync(submodelId, Arg.Any(), Arg.Any()) + .Returns(TestData.CreateSubmodel()); + _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); + _pluginDataHandler + .TryGetValuesAsync(Arg.Any>(), Arg.Any(), submodelId, Arg.Any()) + .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); + _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + } + + private void ArrangeSubmodelBuildForAny(Submodel filledSubmodel) + { + _templateService + .GetFilteredSubmodelTemplateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(TestData.CreateSubmodel()); + _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); + _pluginDataHandler + .TryGetValuesAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(CreateSubmodelTreeNode("") as SemanticTreeNode)); + _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(filledSubmodel); + } + + private void ArrangeSubmodelBuild_PluginThrows(Exception exception) + { + _templateService + .GetFilteredSubmodelTemplateAsync(SubmodelId, Arg.Any(), Arg.Any()) + .Returns(TestData.CreateSubmodel()); + _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); + _pluginDataHandler + .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) + .ThrowsAsync(exception); + } + + private void ArrangeSemanticPipeline(ISubmodel template, Submodel filled) + { + _semanticIdHandler.Extract(template).Returns(CreateSubmodelTreeNode("")); + _pluginDataHandler + .TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()) + .Returns(CreateSubmodelTreeNode("Test John Doe")); + _semanticIdHandler.FillOutTemplate(template, Arg.Any()).Returns(filled); + } + + private void ArrangeShellsResponse(List descriptors) + { + _pluginDataHandler + .GetDataForShellsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ShellDescriptorsMetaData { ShellDescriptors = descriptors }); + } + + private void ArrangeSubmodelRefsForShell(string shellId, List submodelIds) + { + var refs = submodelIds.Select(id => (IReference)new Reference(ReferenceTypes.ModelReference, [new Key(KeyTypes.Submodel, id)])).ToList(); + _aasRepositoryTemplateService.GetSubmodelRefByIdAsync(shellId, Arg.Any()).Returns(refs); + } + + private void ArrangeValidateSemanticIdFilter(string submodelId, bool result) + { + _templateService.ValidateSemanticIdFilter(submodelId, Arg.Any()).Returns(result); + } + + private void ArrangeValidateSemanticIdFilterForAll(bool result) + { + _templateService.ValidateSemanticIdFilter(Arg.Any(), Arg.Any()).Returns(result); + } + + private void ArrangeAttachmentElement(string idShortPath, ISubmodelElement element) + { + var template = TestData.CreateSubmodelWithElement(element, idShortPath); + _templateService.GetSubmodelTemplateAsync(SubmodelId, idShortPath, Arg.Any(), Arg.Any()).Returns(template); + _semanticIdHandler.Extract(Arg.Any()).Returns(CreateSubmodelTreeNode("")); + _pluginDataHandler.TryGetValuesAsync(Arg.Any>(), Arg.Any(), SubmodelId, Arg.Any()).Returns(CreateSubmodelTreeNode("")); + _semanticIdHandler.FillOutTemplate(Arg.Any(), Arg.Any()).Returns(template); + _semanticIdHandler.Extract(Arg.Any(), idShortPath).Returns(element); + } + + #endregion } diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs index 11d29ca9..a3b741f2 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs @@ -1,8 +1,10 @@ -using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Base; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasEnvironment.Providers; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.SubmodelRepository; +using AAS.TwinEngine.DataEngine.DomainModel.Shared; +using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository; using AasCore.Aas3_1; @@ -27,22 +29,24 @@ public class SubmodelTemplateServiceTests public SubmodelTemplateServiceTests() => _sut = new SubmodelTemplateService(_templateProvider, _mappingProvider, _logger); + #region Constructor + [Fact] public void Constructor_ThrowsInvalidDependencyException_WhenTemplateProviderIsNull() { - ITemplateProvider? templateProvider = null; - - var ex = Assert.Throws(() => new SubmodelTemplateService(templateProvider!, _mappingProvider, _logger)); + Assert.Throws(() => new SubmodelTemplateService(null!, _mappingProvider, _logger)); } [Fact] public void Constructor_ThrowsInvalidDependencyException_WhenTemplateMappingProviderIsNull() { - ISubmodelTemplateMappingProvider? templateMappingProvider = null; - - var ex = Assert.Throws(() => new SubmodelTemplateService(_templateProvider, templateMappingProvider!, _logger)); + Assert.Throws(() => new SubmodelTemplateService(_templateProvider, null!, _logger)); } + #endregion + + #region GetSubmodelTemplateAsync (single param) + [Fact] public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenValidInput() { @@ -57,47 +61,88 @@ public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenValidInput() } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsBadRequestException_WhenSubmodelIdIsNull() + public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenSubmodelIdIsNull() { - string? submodelId = null; + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(null!, CancellationToken.None)); + } - var exception = await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(submodelId!, CancellationToken.None)); - Assert.Equal("Internal Server Error.", exception.Message); + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenSubmodelIdIsEmpty() + { + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync("", CancellationToken.None)); } [Fact] - public async Task GetSubmodelTemplateAsync_ReturnsNull_WhenElementNotFound() + public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenSubmodelIdIsWhitespace() + { + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(" ", CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsSubmodelNotFoundException_WhenResourceNotFound() { - const string IdShortPath = "InvalidElement"; - var expectedSubmodel = TestData.CreateSubmodel(); _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(expectedSubmodel); + .ThrowsAsync(new ResourceNotFoundException()); - var exception = await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(SubmodelId, IdShortPath, null, CancellationToken.None)); - Assert.Equal("Submodel Element not found.", exception.Message); + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsException_WhenSubmodelIdIsInvalid() + public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenResponseParsingFails() { - string? submodelId = null; + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) + .ThrowsAsync(new ResponseParsingException()); + + var exception = await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); - var exception = await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(submodelId!, "idShort", null, CancellationToken.None)); Assert.Equal("Internal Server Error.", exception.Message); } + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsTemplateRequestFailedException_WhenRequestTimesOut() + { + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) + .ThrowsAsync(new RequestTimeoutException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsRepositoryNotAvailableException_WhenServiceUnavailable() + { + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) + .ThrowsAsync(new ServiceUnavailableException("http://fake-url")); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); + } + + #endregion + + #region GetSubmodelTemplateAsync (with idShortPath) + [Fact] public async Task GetSubmodelTemplateAsync_ReturnsElement_WhenSingleProperty() { - const string IdShortPath = "ManufacturerName"; + const string idShortPath = "ManufacturerName"; var expectedSubmodel = TestData.CreateSubmodel(); var expectedElement = TestData.CreateSubmodelWithoutExtraElements(); _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) .Returns(expectedSubmodel); - var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, IdShortPath, null, CancellationToken.None); + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, idShortPath, null, CancellationToken.None); + Assert.Equal(GetSemanticId(expectedElement), GetSemanticId(result)); Assert.Equal(expectedElement.SubmodelElements!.Count, result.SubmodelElements!.Count); Assert.Single(expectedElement.SubmodelElements); @@ -106,51 +151,142 @@ public async Task GetSubmodelTemplateAsync_ReturnsElement_WhenSingleProperty() [Fact] public async Task GetSubmodelTemplateAsync_ReturnsCustomSubmodel_WhenNestedProperty() { - const string IdShortPath = "ContactInformation.ContactName"; + const string idShortPath = "ContactInformation.ContactName"; var expectedSubmodel = TestData.CreateSubmodel(); var expectedElement = TestData.CreateSubmodelWithoutExtraElementsNested(); _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) .Returns(expectedSubmodel); - var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, IdShortPath, null, CancellationToken.None); + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, idShortPath, null, CancellationToken.None); Assert.Equal(GetSemanticId(expectedElement), GetSemanticId(result)); Assert.Equal(expectedElement.SubmodelElements!.Count, result.SubmodelElements!.Count); Assert.Single(expectedElement.SubmodelElements); } - private static string GetSemanticId(IHasSemantics hasSemantics) => hasSemantics.SemanticId?.Keys?.FirstOrDefault()?.Value ?? string.Empty; + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsSubmodelElementNotFoundException_WhenElementNotFound() + { + const string idShortPath = "InvalidElement"; + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) + .Returns(TestData.CreateSubmodel()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, idShortPath, null, CancellationToken.None)); + } [Fact] - public async Task GetSubmodelTemplateAsync_ReturnsSubmodelElementNotFoundException_WhenNotFindTheSubmodelElement() + public async Task GetSubmodelTemplateAsync_ThrowsSubmodelElementNotFoundException_WhenNestedPathInvalid() { - const string IdShortPath = "ContactInformation0.InvalidIdShort"; - var expectedSubmodel = TestData.CreateSubmodel(); + const string idShortPath = "ContactInformation0.InvalidIdShort"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(expectedSubmodel); + .Returns(TestData.CreateSubmodel()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, idShortPath, null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsInternalDataProcessingException_WhenSubmodelIdIsEmpty() + { + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync("", "ContactInformation0", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsInternalDataProcessingException_WhenSubmodelIdIsNull() + { + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(null!, "idShort", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsInvalidDependencyException_WhenIdShortPathIsEmpty() + { + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, "", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsInvalidDependencyException_WhenIdShortPathIsWhitespace() + { + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, " ", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsSubmodelElementNotFoundException_WhenResourceNotFound() + { + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) + .ThrowsAsync(new ResourceNotFoundException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, "SomePath", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsInternalDataProcessingException_WhenResponseParsingFails() + { + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, Arg.Any(), Arg.Any()) + .ThrowsAsync(new ResponseParsingException()); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, "SomePath", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsTemplateRequestFailedException_WhenRequestTimesOut() + { + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, Arg.Any(), Arg.Any()) + .ThrowsAsync(new RequestTimeoutException()); - var exception = await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(SubmodelId, IdShortPath, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, "SomePath", null, CancellationToken.None)); } [Fact] - public async Task GetSubmodelElementTemplateAsync_ThrowsBadRequestException_WhenSubmodelIdIsEmpty() + public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsRepositoryNotAvailableException_WhenServiceUnavailable() { - const string IdShortPath = "ContactInformation0"; + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, Arg.Any(), Arg.Any()) + .ThrowsAsync(new ServiceUnavailableException("down")); + + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, "SomePath", null, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_WithIdShortPath_PassesQueryOptionsToProvider() + { + const string idShortPath = "ManufacturerName"; + var queryOptions = new SubmodelQueryOptions("deep", "withBlobValue"); + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, queryOptions, Arg.Any()) + .Returns(TestData.CreateSubmodel()); - var exception = await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync("", IdShortPath, null, CancellationToken.None)); - Assert.IsType(exception); + await _sut.GetSubmodelTemplateAsync(SubmodelId, idShortPath, queryOptions, CancellationToken.None); + + await _templateProvider.Received(1).GetFilteredSubmodelTemplateAsync(TemplateId, queryOptions, Arg.Any()); } + #endregion + + #region GetSubmodelTemplateAsync (list index paths) + [Fact] public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenPathContainsListIndex() { var expectedSubmodel = TestData.CreateSubmodelWithModel3DList(); - var path = "Model3D[0].ModelDataFile"; + const string path = "Model3D[0].ModelDataFile"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(expectedSubmodel); + .Returns(expectedSubmodel); var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None); @@ -171,12 +307,12 @@ public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenPathContainsListI public async Task GetSubmodelTemplateAsync_WithListIndexPath_ReturnsSubmodelWithCorrectIndexedElement() { var expectedSubmodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "Model3D[0]"; + const string path = "Model3D[0]"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(expectedSubmodel); + .Returns(expectedSubmodel); - var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, Path, null, CancellationToken.None); + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None); Assert.Equal(GetSemanticId(expectedSubmodel), GetSemanticId(result)); @@ -194,13 +330,13 @@ public async Task GetSubmodelTemplateAsync_WithListIndexPath_ReturnsSubmodelWith public async Task GetSubmodelTemplateAsync_Supports_UrlEncoded_ListIndex() { var submodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "Model3D%5B0%5D.ModelDataFile"; + const string path = "Model3D%5B0%5D.ModelDataFile"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(submodel); + .Returns(submodel); - var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, Path, null, CancellationToken.None); + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None); Assert.NotNull(result); } @@ -209,14 +345,14 @@ public async Task GetSubmodelTemplateAsync_Supports_UrlEncoded_ListIndex() public async Task GetSubmodelTemplateAsync_Throws_When_ListIndex_IsNegative() { var submodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "Model3D[-1]"; + const string path = "Model3D[-1]"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(submodel); + .Returns(submodel); - await Assert.ThrowsAsync( - () => _sut.GetSubmodelTemplateAsync(SubmodelId, Path, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None)); } [Fact] @@ -224,12 +360,12 @@ public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenTypeValueListElem { var expectedSubmodel = TestData.CreateSubmodelWithModel3DList(); var submodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "Model3D[5].ModelDataFile"; + const string path = "Model3D[5].ModelDataFile"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(submodel); + .Returns(submodel); - var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, Path, null, CancellationToken.None); + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None); Assert.Equal(GetSemanticId(expectedSubmodel), GetSemanticId(result)); @@ -245,89 +381,85 @@ public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenTypeValueListElem } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenTypeValueListElementIsSubmodelProperty_AndListIndexExceedsAvailableElements() + public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenTypeValueListElementIsProperty_AndListIndexExceedsAvailableElements() { var submodel = TestData.CreateSubmodelWithPropertyInsideList(); - const string Path = "listProperty[2]"; + const string path = "listProperty[2]"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(submodel); + .Returns(submodel); - await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(SubmodelId, Path, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None)); } [Fact] public async Task GetSubmodelTemplateAsync_ThrowsNotFoundException_WhenPathSegmentHasListElement_AndIsInvalid() { var submodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "Model3D[0].NonExistentFile"; + const string path = "Model3D[0].NonExistentFile"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .Returns(submodel); + .Returns(submodel); - await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(SubmodelId, Path, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetSubmodelTemplateAsync(SubmodelId, path, null, CancellationToken.None)); } + #endregion + + #region ValidateSemanticIdFilter + [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsSubmodelNotFoundException_WhenResourceNotFound() + public async Task ValidateSemanticIdFilter_ReturnsTrue_WhenTemplateIdMatchesFilteredTemplateId() { _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); - _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .ThrowsAsync(new ResourceNotFoundException()); - await Assert.ThrowsAsync( - () => _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); + var result = await _sut.ValidateSemanticIdFilter(SubmodelId, TemplateId); + + Assert.True(result); } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenResponseParsingFails() + public async Task ValidateSemanticIdFilter_ReturnsFalse_WhenTemplateIdDoesNotMatchFilteredTemplateId() { _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); - _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .ThrowsAsync(new ResponseParsingException()); - var exception = await Assert.ThrowsAsync( - () => _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); + var result = await _sut.ValidateSemanticIdFilter(SubmodelId, "different-template-id"); - Assert.Equal("Internal Server Error.", exception.Message); + Assert.False(result); } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsTemplateRequestFailedException_WhenRequestTimesOut() + public async Task ValidateSemanticIdFilter_ReturnsFalse_WhenMappingThrowsResourceNotFoundException() { - _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); - _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .ThrowsAsync(new RequestTimeoutException()); + _mappingProvider.GetTemplateId(SubmodelId).Throws(new ResourceNotFoundException()); + + var result = await _sut.ValidateSemanticIdFilter(SubmodelId, TemplateId); - await Assert.ThrowsAsync( - () => _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); + Assert.False(result); } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsRepositoryNotAvailableException_WhenServiceUnavailable() + public async Task ValidateSemanticIdFilter_ThrowsInternalDataProcessingException_WhenSubmodelIdIsNull() { - _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); - _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .ThrowsAsync(new ServiceUnavailableException("http://fake-url")); - - await Assert.ThrowsAsync( - () => _sut.GetSubmodelTemplateAsync(SubmodelId, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.ValidateSemanticIdFilter(null!, TemplateId)); } [Fact] - public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsSubmodelElementNotFoundException_WhenResourceNotFound() + public async Task ValidateSemanticIdFilter_ThrowsInternalDataProcessingException_WhenSubmodelIdIsEmpty() { - _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); - _templateProvider.GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) - .ThrowsAsync(new ResourceNotFoundException()); - - await Assert.ThrowsAsync( - () => _sut.GetSubmodelTemplateAsync(SubmodelId, "SomePath", null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.ValidateSemanticIdFilter("", TemplateId)); } + + #endregion + #region GetFilteredSubmodelTemplateAsync [Fact] - public async Task GetFilteredSubmodelTemplateAsync_ReturnsTemplate_WhenTemplateIdMatches() + public async Task GetFilteredSubmodelTemplateAsync_ReturnsTemplate_WhenValid() { var expectedSubmodel = Substitute.For(); _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); @@ -335,29 +467,37 @@ public async Task GetFilteredSubmodelTemplateAsync_ReturnsTemplate_WhenTemplateI .GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) .Returns(expectedSubmodel); - var result = await _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, TemplateId, null, CancellationToken.None); + var result = await _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, null, CancellationToken.None); Assert.Equal(expectedSubmodel, result); } [Fact] - public async Task GetFilteredSubmodelTemplateAsync_ReturnsNull_WhenFilteredTemplateIdDoesNotMatch() + public async Task GetFilteredSubmodelTemplateAsync_PassesQueryOptionsToProvider() { + var queryOptions = new SubmodelQueryOptions("deep", "withBlobValue"); _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider + .GetFilteredSubmodelTemplateAsync(TemplateId, queryOptions, Arg.Any()) + .Returns(Substitute.For()); - var result = await _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, "different-template-id", null, CancellationToken.None); + await _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, queryOptions, CancellationToken.None); - Assert.Null(result); + await _templateProvider.Received(1).GetFilteredSubmodelTemplateAsync(TemplateId, queryOptions, Arg.Any()); } [Fact] - public async Task GetFilteredSubmodelTemplateAsync_ReturnsNull_WhenMappingThrowsResourceNotFoundException() + public async Task GetFilteredSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenSubmodelIdIsNull() { - _mappingProvider.GetTemplateId(SubmodelId).Throws(new ResourceNotFoundException()); - - var result = await _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, TemplateId, null, CancellationToken.None); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateAsync(null!, null, CancellationToken.None)); + } - Assert.Null(result); + [Fact] + public async Task GetFilteredSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenSubmodelIdIsEmpty() + { + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateAsync("", null, CancellationToken.None)); } [Fact] @@ -368,8 +508,8 @@ public async Task GetFilteredSubmodelTemplateAsync_ThrowsInternalDataProcessingE .GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) .ThrowsAsync(new ResponseParsingException()); - await Assert.ThrowsAsync( - () => _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, TemplateId, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, null, CancellationToken.None)); } [Fact] @@ -380,8 +520,8 @@ public async Task GetFilteredSubmodelTemplateAsync_ThrowsTemplateRequestFailedEx .GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) .ThrowsAsync(new RequestTimeoutException()); - await Assert.ThrowsAsync( - () => _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, TemplateId, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, null, CancellationToken.None)); } [Fact] @@ -392,8 +532,8 @@ public async Task GetFilteredSubmodelTemplateAsync_ThrowsRepositoryNotAvailableE .GetFilteredSubmodelTemplateAsync(TemplateId, null, Arg.Any()) .ThrowsAsync(new ServiceUnavailableException("down")); - await Assert.ThrowsAsync( - () => _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, TemplateId, null, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateAsync(SubmodelId, null, CancellationToken.None)); } #endregion @@ -436,8 +576,8 @@ public async Task GetFilteredSubmodelTemplateIdAsync_ThrowsInternalDataProcessin .GetFilteredSubmodelTemplateBySemanticIdAsync(SemanticId, Arg.Any()) .ThrowsAsync(new ResponseParsingException()); - await Assert.ThrowsAsync( - () => _sut.GetFilteredSubmodelTemplateIdAsync(SemanticId, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateIdAsync(SemanticId, CancellationToken.None)); } [Fact] @@ -448,8 +588,8 @@ public async Task GetFilteredSubmodelTemplateIdAsync_ThrowsTemplateRequestFailed .GetFilteredSubmodelTemplateBySemanticIdAsync(SemanticId, Arg.Any()) .ThrowsAsync(new RequestTimeoutException()); - await Assert.ThrowsAsync( - () => _sut.GetFilteredSubmodelTemplateIdAsync(SemanticId, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateIdAsync(SemanticId, CancellationToken.None)); } [Fact] @@ -460,9 +600,11 @@ public async Task GetFilteredSubmodelTemplateIdAsync_ThrowsRepositoryNotAvailabl .GetFilteredSubmodelTemplateBySemanticIdAsync(SemanticId, Arg.Any()) .ThrowsAsync(new ServiceUnavailableException("down")); - await Assert.ThrowsAsync( - () => _sut.GetFilteredSubmodelTemplateIdAsync(SemanticId, CancellationToken.None)); + await Assert.ThrowsAsync(() => + _sut.GetFilteredSubmodelTemplateIdAsync(SemanticId, CancellationToken.None)); } #endregion + + private static string GetSemanticId(IHasSemantics hasSemantics) => hasSemantics.SemanticId?.Keys?.FirstOrDefault()?.Value ?? string.Empty; } diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Http/Clients/Caching/CachedGetRequestClientTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Http/Clients/Caching/CachedGetRequestClientTests.cs index 068b4cd1..d8484ce3 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Http/Clients/Caching/CachedGetRequestClientTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Http/Clients/Caching/CachedGetRequestClientTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Security.Claims; using System.Security.Cryptography; using System.Text; diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandlerTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandlerTests.cs index 41d59a0c..89f903a5 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandlerTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandlerTests.cs @@ -188,7 +188,7 @@ public async Task GetDataForAllShellDescriptorsAsync_ReturnsListWithHrefSet() .Returns([new($"{HttpClientNames.PluginDataProviderPrefix}PluginA", "")]); _pluginDataProvider - .GetDataForAllShellDescriptorsAsync(null, null, Arg.Any>(), Arg.Any()) + .GetDataForAllShellDescriptorsAsync(Arg.Any(), null, Arg.Any>(), Arg.Any()) .Returns([json]); var result = await _sut.GetDataForAllShellDescriptorsAsync(null, null, manifests, CancellationToken.None); @@ -220,7 +220,7 @@ public async Task GetDataForAllShellDescriptorsAsync_Throws_WhenDeserializationF }; _pluginDataProvider - .GetDataForAllShellDescriptorsAsync(null, null, Arg.Any>(), Arg.Any()) + .GetDataForAllShellDescriptorsAsync(Arg.Any(), null, Arg.Any>(), Arg.Any()) .Returns(["null"]); await Assert.ThrowsAsync(() => @@ -263,7 +263,7 @@ public async Task GetDataForAllShellDescriptorsAsync_ThrowsAndLogsIdentifiers_Wh }; _pluginDataProvider - .GetDataForAllShellDescriptorsAsync(null, null, Arg.Any>(), Arg.Any()) + .GetDataForAllShellDescriptorsAsync(Arg.Any(), null, Arg.Any>(), Arg.Any()) .Returns([json]); await Assert.ThrowsAsync(() => @@ -315,7 +315,7 @@ public async Task GetDataForAllShellDescriptorsAsync_ThrowsAndLogsNullMarkers_Wh }; _pluginDataProvider - .GetDataForAllShellDescriptorsAsync(null, null, Arg.Any>(), Arg.Any()) + .GetDataForAllShellDescriptorsAsync(Arg.Any(), null, Arg.Any>(), Arg.Any()) .Returns([json]); await Assert.ThrowsAsync(() => @@ -624,20 +624,15 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_ReturnsShellDescript } """; - var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(responseJson, Encoding.UTF8, "application/json") - }; - _pluginDataProvider - .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns([responseJson]); + .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List { responseJson }); const string Json = """[{"name":"sn","value":"123"}]"""; var assetIds = JsonSerializer.Deserialize>(Json)!; var filter = new ShellSearchFilter { SpecificAssetIds = assetIds }; - var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, CancellationToken.None); + var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None); Assert.NotNull(result); Assert.Single(result.ShellDescriptors); @@ -667,18 +662,13 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenNullDeserializat _pluginRequestBuilder.Build(Arg.Any>()) .Returns([new($"{HttpClientNames.PluginDataProviderPrefix}PluginA", "")]); - var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("null", Encoding.UTF8, "application/json") - }; - _pluginDataProvider - .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(["null"]); + .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List { "null" }); var filter = new ShellSearchFilter { SpecificAssetIds = [] }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, CancellationToken.None)); + _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None)); } [Fact] @@ -704,18 +694,13 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenInvalidJson_Thro _pluginRequestBuilder.Build(Arg.Any>()) .Returns([new($"{HttpClientNames.PluginDataProviderPrefix}PluginA", "")]); - var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("invalid json!", Encoding.UTF8, "application/json") - }; - _pluginDataProvider - .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(["invalid json!"]); + .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List { "invalid json!" }); var filter = new ShellSearchFilter { SpecificAssetIds = [] }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, CancellationToken.None)); + _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None)); } [Fact] @@ -751,17 +736,12 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_SetsHrefOnResults() } """; - var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(responseJson, Encoding.UTF8, "application/json") - }; - _pluginDataProvider - .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns([responseJson]); + .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List { responseJson }); var filter = new ShellSearchFilter { SpecificAssetIds = [] }; - var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, CancellationToken.None); + var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None); Assert.Equal(2, result.ShellDescriptors.Count); Assert.All(result.ShellDescriptors, dto => Assert.StartsWith("https://www.mm-software.com/shells/", dto.Href)); @@ -786,7 +766,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenNoAvailablePlugi var filter = new ShellSearchFilter { SpecificAssetIds = [] }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, CancellationToken.None)); + _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None)); } [Fact] @@ -821,25 +801,20 @@ public async Task GetDataForShellsByAssetIdsAsync_WithIdShort_PassesIdShort() } """; - var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(responseJson, Encoding.UTF8, "application/json") - }; - const string targetIdShort = "Motor001"; _pluginDataProvider - .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), null, targetIdShort, Arg.Any()) - .Returns([responseJson]); + .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Is(s => s == null), targetIdShort, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List { responseJson }); var filter = new ShellSearchFilter { IdShort = targetIdShort }; - var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, CancellationToken.None); + var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None); Assert.NotNull(result); Assert.Single(result.ShellDescriptors); Assert.Equal("urn:aas:001", result.ShellDescriptors[0].Id); await _pluginDataProvider.Received(1) - .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), null, targetIdShort, Arg.Any()); + .GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any>(), Arg.Is(s => s == null), targetIdShort, Arg.Any(), Arg.Any(), Arg.Any()); } private const string AssetData = """ diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProviderTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProviderTests.cs index 4f6a5bc0..825bf51b 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProviderTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProviderTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; @@ -391,7 +391,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_ReturnsContent() var assetIdsHeaderValue = """[{"name":"SerialNumber","value":"SN-4711"}]"""; - var result = await _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, assetIdsHeaderValue, null, CancellationToken.None); + var result = await _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, assetIdsHeaderValue, null, null, null, CancellationToken.None); Assert.NotNull(result); Assert.Single(result); @@ -431,7 +431,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WithIdShort_AddsIdSh const string idShort = "test-idshort-value"; - var result = await _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, null, idShort, CancellationToken.None); + var result = await _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, null, idShort, null, null, CancellationToken.None); Assert.NotNull(result); Assert.Single(result); @@ -461,7 +461,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenNotFound_ThrowsR var metadata = new List { new(httpClientName, "") }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None)); + _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None)); } [Fact] @@ -481,7 +481,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenUnauthorized_Thr var metadata = new List { new(httpClientName, "") }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None)); + _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None)); } [Fact] @@ -495,7 +495,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenTimeout_ThrowsRe var metadata = new List { new(httpClientName, "") }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None)); + _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None)); } [Fact] @@ -504,7 +504,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WithNullRequest_Skip var metadata = new List { null! }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None)); + _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None)); } [Fact] @@ -524,7 +524,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenForbidden_Throws var metadata = new List { new(httpClientName, "") }; await Assert.ThrowsAsync(() => - _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None)); + _sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None)); } private static List GetTestShellDescriptorDataList() diff --git a/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/Requests/GetAllSubmodelsRequest.cs b/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/Requests/GetAllSubmodelsRequest.cs index 50f04779..13caa939 100644 --- a/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/Requests/GetAllSubmodelsRequest.cs +++ b/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/Requests/GetAllSubmodelsRequest.cs @@ -1,19 +1,12 @@ namespace AAS.TwinEngine.DataEngine.Api.SubmodelRepository.Requests; -public record GetAllSubmodelsRequest -{ - public string? SemanticId { get; set; } - - public string? IdShort { get; set; } - - public int? Limit { get; set; } - - public string? Cursor { get; set; } - - public Level? Level { get; set; } - - public Extent? Extent { get; set; } -} +public record GetAllSubmodelsRequest( + string? SemanticId, + string? IdShort, + int? Limit, + string? Cursor, + Level? Level, + Extent? Extent); public enum Level { diff --git a/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/SubmodelRepositoryController.cs b/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/SubmodelRepositoryController.cs index e4490393..f4d1fd1d 100644 --- a/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/SubmodelRepositoryController.cs +++ b/source/AAS.TwinEngine.DataEngine/Api/SubmodelRepository/SubmodelRepositoryController.cs @@ -56,7 +56,7 @@ public async Task> GetAllSubmodelsAsync( { logger.LogInformation("Get All Submodels"); - var request = new GetAllSubmodelsRequest{SemanticId = semanticId, IdShort = idShort, Limit = limit, Cursor = cursor, Level = level, Extent = extent}; + var request = new GetAllSubmodelsRequest(semanticId, idShort, limit, cursor, level, extent); var response = await submodelRepositoryHandler .GetAllSubmodels(request, cancellationToken) diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Extensions/PagingExtensions.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Extensions/PagingExtensions.cs index 67e0af42..2bd58064 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Extensions/PagingExtensions.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Extensions/PagingExtensions.cs @@ -1,4 +1,5 @@ using AAS.TwinEngine.DataEngine.DomainModel.Shared; +using AAS.TwinEngine.DataEngine.ServiceConfiguration.Config; namespace AAS.TwinEngine.DataEngine.ApplicationLogic.Extensions; @@ -17,7 +18,7 @@ public static (IList Items, PagingMetaData PagingMetaData) GetPagedResult( startIndex = allItems.ToList().FindIndex(item => getId(item) == lastId) + 1; } - var pageSize = limit ?? 100; + var pageSize = limit ?? GeneralConfig.DefaultPaginationLimit; var pagedItems = allItems.Skip(startIndex).Take(pageSize).ToList(); string? nextCursor = null; diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/AasRepository/AasRepositoryService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/AasRepository/AasRepositoryService.cs index 15deadaf..12245c0a 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/AasRepository/AasRepositoryService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/AasRepository/AasRepositoryService.cs @@ -328,7 +328,7 @@ private void FillShellFromMetadata(IAssetAdministrationShell shell, ShellDescrip CancellationToken cancellationToken) { var metadata = await pluginDataHandler - .GetDataForShellsByAssetIdsAsync(pluginManifestConflictHandler.Manifests, filter, cancellationToken) + .GetDataForShellsByAssetIdsAsync(pluginManifestConflictHandler.Manifests, filter, limit, cursor, cancellationToken) .ConfigureAwait(false); var allMetadata = metadata.ShellDescriptors? diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Discovery/AssetIdSearchService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Discovery/AssetIdSearchService.cs index e6c17cfe..398c83d9 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Discovery/AssetIdSearchService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Discovery/AssetIdSearchService.cs @@ -1,4 +1,4 @@ -using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Base; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure; using AAS.TwinEngine.DataEngine.ApplicationLogic.Extensions; @@ -27,7 +27,7 @@ public async Task SearchShellsByAssetLinkAsync(IList !string.IsNullOrWhiteSpace(m.Id)) @@ -62,7 +62,7 @@ public async Task> GetSpecificAssetIdByAasIdentifierAsyn return specificAssetIds; } - private async Task GetFilteredMetadataAsync(List specificAssetIds, CancellationToken cancellationToken) + private async Task GetFilteredMetadataAsync(List specificAssetIds, int? limit, string? cursor, CancellationToken cancellationToken) { try { @@ -73,7 +73,7 @@ private async Task GetFilteredMetadataAsync(List GetDataForAssetInformationByIdAsync(IReadOnlyList pluginManifests, string id, CancellationToken cancellationToken); - Task GetDataForShellsByAssetIdsAsync(IReadOnlyList pluginManifests, ShellSearchFilter? filter, CancellationToken cancellationToken); + Task GetDataForShellsByAssetIdsAsync(IReadOnlyList pluginManifests, ShellSearchFilter? filter, int? limit, string? cursor, CancellationToken cancellationToken); } diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Plugin/Providers/IPluginDataProvider.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Plugin/Providers/IPluginDataProvider.cs index 3279be54..52bf7cb0 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Plugin/Providers/IPluginDataProvider.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Plugin/Providers/IPluginDataProvider.cs @@ -12,5 +12,5 @@ public interface IPluginDataProvider Task> GetDataForAssetInformationByIdAsync(IList pluginRequests, CancellationToken cancellationToken); - Task> GetDataForShellDescriptorsByAssetIdsAsync(IList pluginRequests, string? assetIdsHeaderValue, string? idShortHeaderValue, CancellationToken cancellationToken); + Task> GetDataForShellDescriptorsByAssetIdsAsync(IList pluginRequests, string? assetIdsHeaderValue, string? idShortHeaderValue, int? limit, string? cursor, CancellationToken cancellationToken); } diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Shared/SubmodelPagination.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Shared/SubmodelPagination.cs new file mode 100644 index 00000000..00c7a311 --- /dev/null +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/Shared/SubmodelPagination.cs @@ -0,0 +1,53 @@ +using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository; + +namespace AAS.TwinEngine.DataEngine.ApplicationLogic.Services.Shared; + +internal sealed record SubmodelPageResult(List SubmodelIds, string? NextCursor); + +internal sealed class SubmodelPaginationState(SubmodelPaginationCursor? cursor, int capacity = 0) +{ + public List CollectedIds { get; } = capacity > 0 ? new(capacity) : []; + public string? TrackingAasId { get; set; } = cursor?.AasId; + public string? LastCollectedSubmodelId { get; set; } + public string? ResumeAfterSubmodelId { get; set; } = cursor?.SubmodelId; + + public bool CollectSubmodelIds(IList submodelIds, string shellId, int pageSize) + { + if (submodelIds.Count == 0) + { + TrackingAasId = shellId; + ResumeAfterSubmodelId = null; + return false; + } + + var startIndex = 0; + + if (ResumeAfterSubmodelId is not null) + { + startIndex = submodelIds.IndexOf(ResumeAfterSubmodelId) + 1; + ResumeAfterSubmodelId = null; + } + + for (var i = startIndex; i < submodelIds.Count; i++) + { + CollectedIds.Add(submodelIds[i]); + LastCollectedSubmodelId = submodelIds[i]; + + if (CollectedIds.Count >= pageSize) + { + if (submodelIds[^1] == LastCollectedSubmodelId) + { + TrackingAasId = shellId; + } + + return true; + } + } + + TrackingAasId = shellId; + return false; + } + + public string? BuildNextCursor(int pageSize) => + CollectedIds.Count >= pageSize ? SubmodelPaginationCursor.Encode(LastCollectedSubmodelId, TrackingAasId) : null; +} diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorService.cs index 983a68ef..40123816 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRegistry/SubmodelDescriptorService.cs @@ -1,13 +1,13 @@ -using System.Collections.Concurrent; - -using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Application; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure; using AAS.TwinEngine.DataEngine.ApplicationLogic.Extensions; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasEnvironment.Providers; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasRepository; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.Shared; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.SubmodelRegistry.Providers; using AAS.TwinEngine.DataEngine.DomainModel.Shared; using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRegistry; +using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository; using AAS.TwinEngine.DataEngine.ServiceConfiguration.Config; using Microsoft.Extensions.Options; @@ -29,9 +29,9 @@ public class SubmodelDescriptorService( public async Task GetAllSubmodelDescriptorsAsync(int? limit, string? cursor, CancellationToken cancellationToken) { - var shells = await aasRepositoryService.GetShellsByFiltersAsync(null, null, null, cancellationToken).ConfigureAwait(false); - - var submodelIds = ExtractDistinctSubmodelIds(shells.Result); + var pageSize = limit ?? GeneralConfig.DefaultPaginationLimit; + var paginationResult = await CollectSubmodelDescriptorPageAsync(pageSize, cursor, cancellationToken).ConfigureAwait(false); + var submodelIds = paginationResult.SubmodelIds; using var semaphore = new SemaphoreSlim(_concurrentOperationsLimit, _concurrentOperationsLimit); var descriptorTasks = submodelIds.Select(async submodelId => @@ -62,12 +62,10 @@ public async Task GetAllSubmodelDescriptorsAsync(int? limit throw new SubmodelDescriptorNotFoundException(); } - var (pagedItems, pagingMetaData) = PagingExtensions.GetPagedResult(allDescriptors, d => d.Id!, limit, cursor); - return new SubmodelDescriptors { - PagingMetaData = pagingMetaData, - Result = pagedItems + PagingMetaData = new PagingMetaData { Cursor = paginationResult.NextCursor }, + Result = allDescriptors }; } @@ -139,15 +137,64 @@ private static void SetHref(EndpointData endpoint, string href) endpoint.ProtocolInformation.Href = href; } - private static IList ExtractDistinctSubmodelIds(IList? shells) + private async Task CollectSubmodelDescriptorPageAsync(int pageSize, string? encodedCursor, CancellationToken cancellationToken) + { + var incomingCursor = SubmodelPaginationCursor.Decode(encodedCursor); + var state = new SubmodelPaginationState(incomingCursor); + var pluginCursor = state.TrackingAasId; + + while (state.CollectedIds.Count < pageSize) + { + var shellsResult = await aasRepositoryService.GetShellsByFiltersAsync(null, pageSize, pluginCursor?.EncodeBase64Url(), cancellationToken).ConfigureAwait(false); + + var shellList = shellsResult?.Result?.Where(s => !string.IsNullOrWhiteSpace(s.Id)).ToList() ?? []; + + if (shellList.Count == 0) + { + break; + } + + var limitReached = ProcessShellBatch(shellList, pageSize, state); + + if (limitReached) + { + break; + } + + if (shellsResult.PagingMetaData?.Cursor is null) + { + break; + } + + pluginCursor = state.TrackingAasId; + } + + return new SubmodelPageResult(state.CollectedIds, state.BuildNextCursor(pageSize)); + } + + private static bool ProcessShellBatch(List shellList, int pageSize, SubmodelPaginationState state) + { + foreach (var shell in shellList) + { + var submodelIds = GetSubmodelIdsForShell(shell); + + if (state.CollectSubmodelIds(submodelIds, shell.Id, pageSize)) + { + return true; + } + } + + return false; + } + + private static List GetSubmodelIdsForShell(AasCore.Aas3_1.IAssetAdministrationShell shell) { - return shells? - .SelectMany(shell => shell.Submodels ?? []) - .SelectMany(reference => reference.Keys ?? []) - .Select(key => key.Value) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList() ?? []; + return shell.Submodels? + .SelectMany(reference => reference.Keys ?? []) + .Select(key => key.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() ?? []; } private string GenerateHref(string encodedId) => $"{_baseUrl}{ApiPaths.Submodels}/{encodedId}"; diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/ISubmodelTemplateService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/ISubmodelTemplateService.cs index fa6f2855..87eccad3 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/ISubmodelTemplateService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/ISubmodelTemplateService.cs @@ -11,7 +11,9 @@ public interface ISubmodelTemplateService Task GetSubmodelTemplateAsync(string submodelId, string idShortPath, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken); - Task GetFilteredSubmodelTemplateAsync(string submodelId, string filteredTemplateId, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken); + Task GetFilteredSubmodelTemplateAsync(string submodelId, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken); + + Task ValidateSemanticIdFilter(string submodelId, string filteredTemplateId); Task GetFilteredSubmodelTemplateIdAsync(string semanticId, CancellationToken cancellationToken); } diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryService.cs index cfa30d7e..bbbb86f0 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryService.cs @@ -3,6 +3,7 @@ using AAS.TwinEngine.DataEngine.ApplicationLogic.Extensions; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasRepository; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.Plugin; +using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.Shared; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.Shared.Providers; using AAS.TwinEngine.DataEngine.DomainModel.AasRegistry; using AAS.TwinEngine.DataEngine.DomainModel.AasRepository; @@ -37,7 +38,7 @@ public async Task GetSubmodelAsync(string submodelId, SubmodelQueryOp { return await ExecuteWithExceptionHandlingAsync(async () => { - var submodelTemplate = await submodelTemplateService.GetFilteredSubmodelTemplateAsync(submodelId, null, queryOptions, cancellationToken).ConfigureAwait(false); + var submodelTemplate = await submodelTemplateService.GetFilteredSubmodelTemplateAsync(submodelId, queryOptions, cancellationToken).ConfigureAwait(false); if (submodelTemplate is null) { @@ -46,8 +47,6 @@ public async Task GetSubmodelAsync(string submodelId, SubmodelQueryOp var submodelWithValues = await BuildSubmodelWithValuesAsync(submodelTemplate, submodelId, cancellationToken).ConfigureAwait(false); - submodelWithValues.Id = submodelId; - return submodelWithValues; }, ex => new SubmodelNotFoundException(ex)).ConfigureAwait(false); } @@ -68,14 +67,6 @@ public async Task GetAllSubmodelsAsync(SubmodelSearchFilter? filte { return await ExecuteWithExceptionHandlingAsync(async () => { - var shellSearchFilter = new ShellSearchFilter - { - IdShort = filter?.IdShort - }; - - var shellMetadata = await pluginDataHandler.GetDataForShellsByAssetIdsAsync(pluginManifestConflictHandler.Manifests, shellSearchFilter, cancellationToken).ConfigureAwait(false); - var shellDescriptors = shellMetadata.ShellDescriptors ?? []; - string? filteredTemplateId = null; if (filter?.SemanticId is not null) { @@ -87,84 +78,184 @@ public async Task GetAllSubmodelsAsync(SubmodelSearchFilter? filte } } - var distinctSubmodelIds = await GetDistinctSubmodelIdsAsync(shellDescriptors, cancellationToken).ConfigureAwait(false); + var shellSearchFilter = new ShellSearchFilter + { + IdShort = filter?.IdShort + }; - var (pagedIds, pagingMetaData) = PagingExtensions.GetPagedResult(distinctSubmodelIds, id => id, limit, cursor); + var pageSize = limit ?? GeneralConfig.DefaultPaginationLimit; + var paginationResult = await CollectSubmodelPageAsync(shellSearchFilter, filteredTemplateId, pageSize, cursor, cancellationToken).ConfigureAwait(false); - var submodels = await BuildSubmodelsAsync(pagedIds, filteredTemplateId, queryOptions, cancellationToken).ConfigureAwait(false); + var submodels = await BuildSubmodelsAsync(paginationResult.SubmodelIds, queryOptions, cancellationToken).ConfigureAwait(false); return new SubmodelList { - PagingMetaData = pagingMetaData, + PagingMetaData = new PagingMetaData { Cursor = paginationResult.NextCursor }, Result = submodels }; }, ex => new SubmodelNotFoundException(ex)).ConfigureAwait(false); } - private async Task> GetDistinctSubmodelIdsAsync(List shellDescriptors, CancellationToken cancellationToken) + private async Task CollectSubmodelPageAsync(ShellSearchFilter shellSearchFilter, string? filteredTemplateId, int pageSize, string? encodedCursor, CancellationToken cancellationToken) { - using var semaphore = new SemaphoreSlim(_concurrentOperationsLimit, _concurrentOperationsLimit); - var tasks = shellDescriptors.Where(shell => !string.IsNullOrWhiteSpace(shell.Id)).Select(async shell => + var incomingCursor = SubmodelPaginationCursor.Decode(encodedCursor); + if (incomingCursor is null && !string.IsNullOrWhiteSpace(encodedCursor)) + { + throw new InvalidUserInputException(); + } + var state = new SubmodelPaginationState(incomingCursor, pageSize); + var pluginCursor = state.TrackingAasId; + + while (state.CollectedIds.Count < pageSize) + { + var shellMetadata = await pluginDataHandler.GetDataForShellsByAssetIdsAsync( + pluginManifestConflictHandler.Manifests, shellSearchFilter, pageSize, Base64UrlExtensions.EncodeBase64Url(pluginCursor), cancellationToken).ConfigureAwait(false); + + var shellDescriptors = shellMetadata.ShellDescriptors; + if (shellDescriptors is null || shellDescriptors.Count == 0) { - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - return await aasRepositoryTemplateService.GetSubmodelRefByIdAsync(shell.Id, cancellationToken).ConfigureAwait(false); - } - catch (ResourceNotFoundException ex) - { - logger.LogWarning(ex, "Could not retrieve submodel refs for shell {ShellId}. Skipping shell.", shell.Id); - return []; - } - finally - { - _ = semaphore.Release(); - } - }); + break; + } - var references = await Task.WhenAll(tasks).ConfigureAwait(false); + var limitReached = await ProcessShellBatchAsync(shellDescriptors, filteredTemplateId, pageSize, state, cancellationToken).ConfigureAwait(false); + + if (limitReached) + { + break; + } + + if (shellMetadata.PagingMetaData?.Cursor is null) + { + break; + } + + pluginCursor = state.TrackingAasId; + } - return references - .SelectMany(x => x) - .Select(reference => reference.Keys.FirstOrDefault()?.Value) - .Where(id => !string.IsNullOrWhiteSpace(id)) - .Distinct() - .ToList()!; + return new SubmodelPageResult(state.CollectedIds, state.BuildNextCursor(pageSize)); } - private async Task> BuildSubmodelsAsync(IEnumerable submodelIds, string? filteredTemplateId, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken) + private async Task ProcessShellBatchAsync(IReadOnlyList shellDescriptors, string? filteredTemplateId, int pageSize, SubmodelPaginationState state, CancellationToken cancellationToken) { + var prefetchTasks = new Task>[shellDescriptors.Count]; using var semaphore = new SemaphoreSlim(_concurrentOperationsLimit, _concurrentOperationsLimit); - var tasks = submodelIds.Select(async submodelId => + + for (var idx = 0; idx < shellDescriptors.Count; idx++) { - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try + var shellId = shellDescriptors[idx].Id; + if (string.IsNullOrWhiteSpace(shellId)) { - var template = await submodelTemplateService.GetFilteredSubmodelTemplateAsync(submodelId, filteredTemplateId, queryOptions, cancellationToken).ConfigureAwait(false); + prefetchTasks[idx] = Task.FromResult>([]); + continue; + } - if (template is null) - { - return null; - } + prefetchTasks[idx] = PrefetchSubmodelIdsAsync(shellId, filteredTemplateId, semaphore, cancellationToken); + } - var submodel = await BuildSubmodelWithValuesAsync(template, submodelId, cancellationToken).ConfigureAwait(false); + var allSubmodelIds = await Task.WhenAll(prefetchTasks).ConfigureAwait(false); - return submodel; + for (var idx = 0; idx < shellDescriptors.Count; idx++) + { + var shellId = shellDescriptors[idx].Id; + if (string.IsNullOrWhiteSpace(shellId)) + { + continue; + } + + var submodelIds = allSubmodelIds[idx]; + + if (state.CollectSubmodelIds(submodelIds, shellId, pageSize)) + { + return true; } - finally + } + + return false; + } + + private async Task> PrefetchSubmodelIdsAsync(string shellId, string? filteredTemplateId, SemaphoreSlim semaphore, CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await GetSubmodelIdsForShellAsync(shellId, filteredTemplateId, cancellationToken).ConfigureAwait(false); + } + finally + { + _ = semaphore.Release(); + } + } + + private async Task> GetSubmodelIdsForShellAsync(string shellId, string? filteredTemplateId, CancellationToken cancellationToken) + { + try + { + var references = await aasRepositoryTemplateService.GetSubmodelRefByIdAsync(shellId, cancellationToken).ConfigureAwait(false); + + var submodelIds = references.Select(reference => reference.Keys.FirstOrDefault()?.Value).Where(id => !string.IsNullOrWhiteSpace(id)).ToList(); + + if (string.IsNullOrWhiteSpace(filteredTemplateId)) { - _ = semaphore.Release(); + return submodelIds; } - }); - return [.. (await Task.WhenAll(tasks).ConfigureAwait(false)).OfType()]; + var validationTasks = submodelIds.Select(async id => + new + { + Id = id, + IsValid = await submodelTemplateService.ValidateSemanticIdFilter(id, filteredTemplateId).ConfigureAwait(false) + }); + + var results = await Task.WhenAll(validationTasks).ConfigureAwait(false); + + return [.. results.Where(result => result.IsValid).Select(result => result.Id)]; + } + catch (ResourceNotFoundException ex) + { + logger.LogWarning(ex, "Could not retrieve submodel refs for shell {ShellId}. Skipping shell.", shellId); + + return []; + } + } + + private async Task> BuildSubmodelsAsync(List submodelIds, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(_concurrentOperationsLimit, _concurrentOperationsLimit); + var tasks = new Task[submodelIds.Count]; + + for (var i = 0; i < submodelIds.Count; i++) + { + tasks[i] = BuildSingleSubmodelAsync(submodelIds[i], queryOptions, semaphore, cancellationToken); + } + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + var submodels = new List(results.Length); + submodels.AddRange(results.Where(result => result is not null)); + + return submodels; + } + + private async Task BuildSingleSubmodelAsync(string submodelId, SubmodelQueryOptions? queryOptions, SemaphoreSlim semaphore, CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var template = await submodelTemplateService.GetFilteredSubmodelTemplateAsync(submodelId, queryOptions, cancellationToken).ConfigureAwait(false); + + return await BuildSubmodelWithValuesAsync(template, submodelId, cancellationToken).ConfigureAwait(false); + } + finally + { + _ = semaphore.Release(); + } } public async Task GetAllSubmodelElementsAsync(string submodelId, SubmodelQueryOptions? queryOptions, int? limit, string? cursor, CancellationToken cancellationToken) { return await ExecuteWithExceptionHandlingAsync(async () => { - var submodelTemplate = await submodelTemplateService.GetFilteredSubmodelTemplateAsync(submodelId, null, queryOptions, cancellationToken).ConfigureAwait(false); + var submodelTemplate = await submodelTemplateService.GetFilteredSubmodelTemplateAsync(submodelId, queryOptions, cancellationToken).ConfigureAwait(false); if (submodelTemplate is null) { @@ -197,7 +288,9 @@ private async Task BuildSubmodelWithValuesAsync(ISubmodel template, s var values = await pluginDataHandler.TryGetValuesAsync(pluginManifests, semanticIds, submodelId, cancellationToken).ConfigureAwait(false); - return semanticIdHandler.FillOutTemplate(template, values); + var submodelWithValues = semanticIdHandler.FillOutTemplate(template, values); + submodelWithValues.Id = submodelId; + return submodelWithValues; } private static async Task ExecuteWithExceptionHandlingAsync( diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs index 8f1ba0fa..bd8df631 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs @@ -83,7 +83,7 @@ public async Task GetSubmodelTemplateAsync(string submodelId, string throw new InvalidDependencyException(nameof(idShortPath)); } - public async Task GetFilteredSubmodelTemplateAsync(string submodelId, string filteredTemplateId, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken) + public async Task ValidateSemanticIdFilter(string submodelId, string filteredTemplateId) { ValidateSubmodelId(submodelId); @@ -94,16 +94,25 @@ public async Task GetSubmodelTemplateAsync(string submodelId, string if (filteredTemplateId is not null && templateId != filteredTemplateId) { - return null; + return false; } + return true; } catch (ResourceNotFoundException) { - return null; + return false; } + } + + public async Task GetFilteredSubmodelTemplateAsync(string submodelId, SubmodelQueryOptions? queryOptions, CancellationToken cancellationToken) + { + ValidateSubmodelId(submodelId); + string? templateId; try { + templateId = _submodelTemplateMappingProvider.GetTemplateId(submodelId); + return await _templateProvider.GetFilteredSubmodelTemplateAsync(templateId!, queryOptions, cancellationToken).ConfigureAwait(false); } catch (ResponseParsingException ex) diff --git a/source/AAS.TwinEngine.DataEngine/DomainModel/SubmodelRepository/SubmodelPaginationCursor.cs b/source/AAS.TwinEngine.DataEngine/DomainModel/SubmodelRepository/SubmodelPaginationCursor.cs new file mode 100644 index 00000000..00dd33fa --- /dev/null +++ b/source/AAS.TwinEngine.DataEngine/DomainModel/SubmodelRepository/SubmodelPaginationCursor.cs @@ -0,0 +1,52 @@ +using System.Text; + +using Microsoft.AspNetCore.WebUtilities; + +namespace AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository; + +/// +/// Two-field composite cursor for submodel pagination. +/// Wire format: Base64Url("{SubmodelId}|{AasId}") +/// +public sealed record SubmodelPaginationCursor(string? SubmodelId, string? AasId) +{ + private const char Separator = '|'; + + public static SubmodelPaginationCursor? Decode(string? encodedCursor) + { + if (string.IsNullOrWhiteSpace(encodedCursor)) + { + return null; + } + + string decoded; + try + { + decoded = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(encodedCursor)); + } + catch (FormatException) + { + return null; + } + + var separatorIndex = decoded.IndexOf(Separator); + + if (separatorIndex < 0) + { + return null; + } + + var submodelId = decoded[..separatorIndex]; + var aasId = decoded[(separatorIndex + 1)..]; + + return new SubmodelPaginationCursor( + string.IsNullOrEmpty(submodelId) ? null : submodelId, + string.IsNullOrEmpty(aasId) ? null : aasId); + } + + public static string? Encode(string? submodelId, string? aasId) + { + var logical = $"{submodelId ?? string.Empty}{Separator}{aasId ?? string.Empty}"; + return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(logical)); + } +} diff --git a/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandler.cs b/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandler.cs index be0065e1..d6203903 100644 --- a/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandler.cs +++ b/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandler.cs @@ -74,6 +74,8 @@ public async Task GetDataForAllShellDescriptorsAsync(i { using var activity = DataEngineTracing.StartSpan(DataEngineTracing.Spans.GetPluginMetadataShells); + limit ??= GeneralConfig.DefaultPaginationLimit; + var availablePlugins = multiPluginDataHandler.GetAvailablePlugins(pluginManifests, c => c.HasShellDescriptor); var pluginRequests = pluginRequestBuilder.Build(availablePlugins); @@ -201,7 +203,7 @@ public async Task GetDataForAssetInformationByIdAsync(IReadOnlyList

GetDataForShellsByAssetIdsAsync(IReadOnlyList pluginManifests, ShellSearchFilter? filter, CancellationToken cancellationToken) + public async Task GetDataForShellsByAssetIdsAsync(IReadOnlyList pluginManifests, ShellSearchFilter? filter, int? limit, string? cursor, CancellationToken cancellationToken) { using var activity = DataEngineTracing.StartSpan(DataEngineTracing.Spans.GetPluginMetadataShells); @@ -224,7 +226,9 @@ public async Task GetDataForShellsByAssetIdsAsync(IRea })) : null; - var responses = await pluginDataProvider.GetDataForShellDescriptorsByAssetIdsAsync(pluginRequests, assetIdsHeaderValue, filter?.IdShort, cancellationToken).ConfigureAwait(false); + limit ??= GeneralConfig.DefaultPaginationLimit; + + var responses = await pluginDataProvider.GetDataForShellDescriptorsByAssetIdsAsync(pluginRequests, assetIdsHeaderValue, filter?.IdShort, limit, cursor, cancellationToken).ConfigureAwait(false); var result = new ShellDescriptorsMetaData(); diff --git a/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProvider.cs b/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProvider.cs index f8515da0..0eb5d897 100644 --- a/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProvider.cs +++ b/source/AAS.TwinEngine.DataEngine/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProvider.cs @@ -31,24 +31,24 @@ public async Task> GetDataForSemanticIdsAsync(IList(); - foreach (var pluginRequest in pluginRequests) + + var tasks = pluginRequests.Select(async pluginRequest => { using var httpClient = CreateClient(pluginRequest.HttpClientName); try { using var response = await httpClient.PostAsync(relativeUri, pluginRequest.JsonSchema, cancellationToken).ConfigureAwait(false); - var processedResponse = await ProcessResponseAsync(response, url, cancellationToken).ConfigureAwait(false); - result.Add(processedResponse); + return await ProcessResponseAsync(response, url, cancellationToken).ConfigureAwait(false); } catch (TaskCanceledException) { logger.LogError("Request timed out. Endpoint: {Url}", url); throw new RequestTimeoutException(); } - } + }); - return result; + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + return results.ToList(); } public async Task> GetDataForAllShellDescriptorsAsync( @@ -113,15 +113,14 @@ public Task> GetDataForShellDescriptorByIdAsync(IList> GetDataForAssetInformationByIdAsync(IList pluginRequests, CancellationToken cancellationToken) => GetAndProcessAsync(pluginRequests, AssetInformationEndpoint, cancellationToken); - public async Task> GetDataForShellDescriptorsByAssetIdsAsync(IList pluginRequests, string? assetIdsHeaderValue, string? idShortHeaderValue, CancellationToken cancellationToken) + public async Task> GetDataForShellDescriptorsByAssetIdsAsync(IList pluginRequests, string? assetIdsHeaderValue, string? idShortHeaderValue, int? limit, string? cursor, CancellationToken cancellationToken) { var result = new List(); var exceptions = new List(); foreach (var pluginRequest in pluginRequests) { - var url = BuildUrl(ApiPaths.PluginMetadata, ShellsEndpoint); - + var url = BuildShellsByAssetIdsUrl(limit, cursor); if (pluginRequest == null) { logger.LogWarning("Plugin request is null. Skipping request to {Url}", url); @@ -279,6 +278,26 @@ private static string BuildShellsUrl(int? limit, string? cursor) : BaseUrl; } + private static string BuildShellsByAssetIdsUrl(int? limit, string? cursor) + { + const string BaseUrl = $"/{ApiPaths.PluginMetadata}/{ShellsEndpoint}"; + var queryParams = new Dictionary(); + + if (limit is > 0) + { + queryParams["limit"] = limit.Value.ToString(); + } + + if (!string.IsNullOrWhiteSpace(cursor)) + { + queryParams["cursor"] = cursor; + } + + return queryParams.Count > 0 + ? QueryHelpers.AddQueryString(BaseUrl, queryParams) + : BaseUrl; + } + private static Exception HandleFailureResponse(System.Net.HttpStatusCode statusCode) => statusCode switch { diff --git a/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/GeneralConfig.cs b/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/GeneralConfig.cs index b2d4f417..245dcf43 100644 --- a/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/GeneralConfig.cs +++ b/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/GeneralConfig.cs @@ -10,6 +10,7 @@ namespace AAS.TwinEngine.DataEngine.ServiceConfiguration.Config; public class GeneralConfig { public const string Section = "General"; + public const int DefaultPaginationLimit = 100; public ApiConfiguration ApiConfiguration { get; set; } = new(); public HeaderSanitizationOptions HeaderSanitization { get; set; } = new(); diff --git a/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/TemplateManagementConfig.cs b/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/TemplateManagementConfig.cs index 8eb09ae2..1e34e966 100644 --- a/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/TemplateManagementConfig.cs +++ b/source/AAS.TwinEngine.DataEngine/ServiceConfiguration/Config/TemplateManagementConfig.cs @@ -39,6 +39,6 @@ public class ServiceInstance public Uri? BaseUrl { get; set; } public IList HeaderMappings { get; init; } = []; public string HealthEndpoint { get; set; } = string.Empty; - public int ConcurrentOperationsLimit { get; set; } = 10; + public int ConcurrentOperationsLimit { get; set; } = 100; public int LocalCacheExpirationInMinutes { get; set; } = 5; } diff --git a/source/AAS.TwinEngine.DataEngine/appsettings.development.json b/source/AAS.TwinEngine.DataEngine/appsettings.development.json index 217f3bc4..cc86c2b7 100644 --- a/source/AAS.TwinEngine.DataEngine/appsettings.development.json +++ b/source/AAS.TwinEngine.DataEngine/appsettings.development.json @@ -172,7 +172,7 @@ "Name": "AasTemplateRepository", "baseUrl": "http://localhost:8081", "healthEndpoint": "/actuator/health", - "ConcurrentOperationsLimit": 10, + "ConcurrentOperationsLimit": 100, "LocalCacheExpirationInMinutes": 5, "headerMappings": [ { @@ -191,7 +191,7 @@ "Name": "SubmodelTemplateRepository", "baseUrl": "http://localhost:8081", "healthEndpoint": "/actuator/health", - "ConcurrentOperationsLimit": 10, + "ConcurrentOperationsLimit": 100, "LocalCacheExpirationInMinutes": 5, "headerMappings": [ { @@ -210,7 +210,7 @@ "Name": "ConceptDescriptionTemplateRepository", "baseUrl": "http://localhost:8081", "healthEndpoint": "/actuator/health", - "ConcurrentOperationsLimit": 10, + "ConcurrentOperationsLimit": 100, "LocalCacheExpirationInMinutes": 5, "headerMappings": [ { @@ -229,7 +229,7 @@ "Name": "AasTemplateRegistry", "baseUrl": "http://localhost:8082", "healthEndpoint": "/actuator/health", - "ConcurrentOperationsLimit": 10, + "ConcurrentOperationsLimit": 100, "LocalCacheExpirationInMinutes": 5, "headerMappings": [ { @@ -243,7 +243,7 @@ "Name": "SubmodelTemplateRegistry", "baseUrl": "http://localhost:8083", "healthEndpoint": "/actuator/health", - "ConcurrentOperationsLimit": 12, + "ConcurrentOperationsLimit": 100, "LocalCacheExpirationInMinutes": 5, "headerMappings": [ { diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetAllShellDescriptors_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetAllShellDescriptors_Expected.json index 35d90ce2..06189776 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetAllShellDescriptors_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetAllShellDescriptors_Expected.json @@ -1,5 +1,5 @@ { - "paging_metadata": { "cursor": null }, + "paging_metadata": { "cursor": "aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vaWRzL2Fhcy8wMDAtMDAz" }, "result": [ { "description": null, @@ -23,7 +23,7 @@ } ], "globalAssetId": "https://mm-software.com/ids/assets/000-001", - "idShort": "M&M01", + "idShort": "MM01", "id": "https://mm-software.com/ids/aas/000-001", "specificAssetIds": [ { @@ -99,7 +99,7 @@ } ], "globalAssetId": "https://mm-software.com/ids/assets/000-002", - "idShort": "M&M02", + "idShort": "MM02", "id": "https://mm-software.com/ids/aas/000-002", "specificAssetIds": [ { @@ -175,7 +175,7 @@ } ], "globalAssetId": "https://mm-software.com/ids/assets/000-003", - "idShort": "M&M03", + "idShort": "MM03", "id": "https://mm-software.com/ids/aas/000-003", "specificAssetIds": [ { diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetShellDescriptorById_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetShellDescriptorById_Expected.json index 0c2b2c30..42795fd9 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetShellDescriptorById_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRegistry/TestData/GetShellDescriptorById_Expected.json @@ -20,7 +20,7 @@ } ], "globalAssetId": "https://mm-software.com/ids/assets/000-001", - "idShort": "M&M01", + "idShort": "MM01", "id": "https://mm-software.com/ids/aas/000-001", "specificAssetIds": [ { diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/AasRepositoryTests.cs b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/AasRepositoryTests.cs index 3e1946e9..f4d54ecb 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/AasRepositoryTests.cs +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/AasRepositoryTests.cs @@ -51,7 +51,7 @@ public async Task GetAllShells_ByAssetIds() public async Task GetAllShells_ByIdShort() { // Arrange - var idShort = "M%26M03"; + var idShort = "MM03"; var url = $"/shells?idShort={idShort}"; // Act @@ -73,7 +73,7 @@ public async Task GetAllShells_ByAssetId_And_ByIdShort() { // Arrange var assetId = EncodeBase64Url("{\"name\":\"SerialNumber\",\"value\":\"SN-1111\"}"); - var idShort = "M%26M03"; + var idShort = "MM03"; var url = $"/shells?assetIds={assetId}&idShort={idShort}"; // Act diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetId_And_ByIdShort_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetId_And_ByIdShort_Expected.json index 220e6a01..2788bb00 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetId_And_ByIdShort_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetId_And_ByIdShort_Expected.json @@ -4,7 +4,7 @@ }, "result": [ { - "idShort": "M&M03", + "idShort": "MM03", "id": "https://mm-software.com/ids/aas/000-003", "assetInformation": { "assetKind": "Instance", diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetIds_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetIds_Expected.json index 220e6a01..2788bb00 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetIds_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByAssetIds_Expected.json @@ -4,7 +4,7 @@ }, "result": [ { - "idShort": "M&M03", + "idShort": "MM03", "id": "https://mm-software.com/ids/aas/000-003", "assetInformation": { "assetKind": "Instance", diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByIdShort_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByIdShort_Expected.json index 220e6a01..2788bb00 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByIdShort_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByIdShort_Expected.json @@ -4,7 +4,7 @@ }, "result": [ { - "idShort": "M&M03", + "idShort": "MM03", "id": "https://mm-software.com/ids/aas/000-003", "assetInformation": { "assetKind": "Instance", diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByMultipleAssetIds_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByMultipleAssetIds_Expected.json index 4407fafe..1d9928f8 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByMultipleAssetIds_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_ByMultipleAssetIds_Expected.json @@ -4,7 +4,7 @@ }, "result": [ { - "idShort": "M&M02", + "idShort": "MM02", "id": "https://mm-software.com/ids/aas/000-002", "assetInformation": { "assetKind": "Instance", diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithCursor_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithCursor_Expected.json index 83d41d2b..044d4924 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithCursor_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithCursor_Expected.json @@ -4,7 +4,7 @@ }, "result": [ { - "idShort": "M&M02", + "idShort": "MM02", "id": "https://mm-software.com/ids/aas/000-002", "assetInformation": { "assetKind": "Instance", diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithLimit_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithLimit_Expected.json index 543b17bf..7c9ecf6e 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithLimit_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/AasRepository/TestData/GetAllShells_WithLimit_Expected.json @@ -4,7 +4,7 @@ }, "result": [ { - "idShort": "M&M01", + "idShort": "MM01", "id": "https://mm-software.com/ids/aas/000-001", "assetInformation": { "assetKind": "Instance", diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithCursor_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithCursor_Expected.json index d6917b63..8e6d3e3d 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithCursor_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithCursor_Expected.json @@ -1,33 +1,25 @@ { - "paging_metadata": { - "cursor": "aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMi9DdXN0b21TdWJtb2RlbA" - }, + "paging_metadata": { "cursor": "aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMS9Db250YWN0SW5mb3JtYXRpb258" }, "result": [ { "description": [ { "language": "en", - "text": "The Submodel defines a set meta data for the handover of documentation from the manufacturer to the operator for industrial equipment" + "text": "The Submodel HierarchicalStructures identified by its semanticId. The Submodel idShort can be picked freely." } ], "displayName": null, "extensions": null, - "administration": { - "embeddedDataSpecifications": null, - "version": "2", - "revision": "0", - "creator": null, - "templateId": "https://admin-shell.io/idta-02004-2-0" - }, - "idShort": "HandoverDocumentation", - "id": "https://mm-software.com/submodel/000-001/HandoverDocumentation", + "administration": null, + "idShort": "CustomSubmodel", + "id": "https://mm-software.com/submodel/000-001/CustomSubmodel", "semanticId": { - "type": "ModelReference", + "type": "ExternalReference", "referredSemanticId": null, "keys": [ { - "type": "Submodel", - "value": "0173-1#01-AHF578#003" + "type": "GlobalReference", + "value": "https://admin-shell.io/idta/CustomSubmodel/Submodel/Template/0/1" } ] }, @@ -36,7 +28,7 @@ { "interface": "SUBMODEL-3.0", "protocolInformation": { - "href": "http://localhost:8080/submodels/aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMS9IYW5kb3ZlckRvY3VtZW50YXRpb24", + "href": "http://localhost:8080/submodels/aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMS9DdXN0b21TdWJtb2RlbA", "endpointProtocol": "http", "endpointProtocolVersion": null, "subprotocol": null, @@ -48,24 +40,19 @@ ] }, { - "description": [ - { - "language": "en", - "text": "The Submodel HierarchicalStructures identified by its semanticId. The Submodel idShort can be picked freely." - } - ], + "description": null, "displayName": null, "extensions": null, "administration": null, - "idShort": "CustomSubmodel", - "id": "https://mm-software.com/submodel/000-002/CustomSubmodel", + "idShort": "ContactInformations", + "id": "https://mm-software.com/submodel/000-001/ContactInformation", "semanticId": { - "type": "ExternalReference", + "type": "ModelReference", "referredSemanticId": null, "keys": [ { - "type": "GlobalReference", - "value": "https://admin-shell.io/idta/CustomSubmodel/Submodel/Template/0/1" + "type": "Submodel", + "value": "https://admin-shell.io/zvei/nameplate/1/0/ContactInformations" } ] }, @@ -74,7 +61,7 @@ { "interface": "SUBMODEL-3.0", "protocolInformation": { - "href": "http://localhost:8080/submodels/aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMi9DdXN0b21TdWJtb2RlbA", + "href": "http://localhost:8080/submodels/aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMS9Db250YWN0SW5mb3JtYXRpb24", "endpointProtocol": "http", "endpointProtocolVersion": null, "subprotocol": null, diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithLimitOne_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithLimitOne_Expected.json index 6f2f0df2..5a5a92c2 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithLimitOne_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRegistry/TestData/GetAllSubmodelDescriptors_WithLimitOne_Expected.json @@ -1,7 +1,5 @@ { - "paging_metadata": { - "cursor": "aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMS9DdXN0b21TdWJtb2RlbA" - }, + "paging_metadata": { "cursor": "aHR0cHM6Ly9tbS1zb2Z0d2FyZS5jb20vc3VibW9kZWwvMDAwLTAwMS9DdXN0b21TdWJtb2RlbHw" }, "result": [ { "description": [ diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/GetAllSubmodelsTests.cs b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/GetAllSubmodelsTests.cs index bdb1575b..0d874cbf 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/GetAllSubmodelsTests.cs +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/GetAllSubmodelsTests.cs @@ -73,7 +73,7 @@ public async Task GetAllSubmodels_WithSemanticId_ShouldReturnFilteredResults() [Fact] public async Task GetAllSubmodels_WithIdShort_ShouldReturnMatchingSubmodels() { - var idShort = "M%26M01"; + var idShort = "MM01"; var response = await ApiContext.GetAsync($"/submodels/?idShort={idShort}"); AssertSuccessResponse(response); diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/SubmodelTests.cs b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/SubmodelTests.cs index e54c4a10..c47ead8d 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/SubmodelTests.cs +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/SubmodelTests.cs @@ -52,7 +52,7 @@ public async Task GetAllSubmodels_By_SemanticId_should_return_success_and_conten public async Task GetAllSubmodels_ByIdShort() { // Arrange - var idShort = "M%26M03"; + var idShort = "MM03"; var url = $"/submodels?idShort={idShort}"; // Act diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_ByIdShort_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_ByIdShort_Expected.json index 715d368c..a45cf203 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_ByIdShort_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_ByIdShort_Expected.json @@ -1,7 +1,5 @@ { - "paging_metadata": { - "cursor": null - }, + "paging_metadata": { "cursor": null }, "result": [ { "idShort": "CustomSubmodel", @@ -11,7 +9,7 @@ "text": "The Submodel HierarchicalStructures identified by its semanticId. The Submodel idShort can be picked freely." } ], - "id": "https://admin-shell.io/idta/CustomSubmodel/Template/0/1", + "id": "https://mm-software.com/submodel/000-003/CustomSubmodel", "kind": "Instance", "semanticId": { "type": "ExternalReference", @@ -1608,7 +1606,7 @@ }, { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-003/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -3456,7 +3454,7 @@ "revision": "0", "templateId": "https://admin-shell.io/idta-02004-2-0" }, - "id": "https://admin-shell.io/idta/SubmodelTemplate/HandoverDocumentation/2/0", + "id": "https://mm-software.com/submodel/000-003/HandoverDocumentation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -10167,4 +10165,4 @@ "modelType": "Submodel" } ] -} \ No newline at end of file +} diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_By_SemanticId_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_By_SemanticId_Expected.json index 4b3edcd7..162103a8 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_By_SemanticId_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_By_SemanticId_Expected.json @@ -1,11 +1,9 @@ { - "paging_metadata": { - "cursor": null - }, + "paging_metadata": { "cursor": null }, "result": [ { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-001/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -1310,7 +1308,7 @@ }, { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-002/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -2503,7 +2501,7 @@ }, { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-003/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -4339,4 +4337,4 @@ "modelType": "Submodel" } ] -} \ No newline at end of file +} diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_Expected.json b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_Expected.json index d527d531..7e1e2d46 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_Expected.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin.PlaywrightTests/SubmodelRepository/TestData/GetAllSubmodels_Expected.json @@ -1,7 +1,5 @@ { - "paging_metadata": { - "cursor": null - }, + "paging_metadata": { "cursor": null }, "result": [ { "idShort": "CustomSubmodel", @@ -11,7 +9,7 @@ "text": "The Submodel HierarchicalStructures identified by its semanticId. The Submodel idShort can be picked freely." } ], - "id": "https://admin-shell.io/idta/CustomSubmodel/Template/0/1", + "id": "https://mm-software.com/submodel/000-001/CustomSubmodel", "kind": "Instance", "semanticId": { "type": "ExternalReference", @@ -1608,7 +1606,7 @@ }, { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-001/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -2924,7 +2922,7 @@ "revision": "0", "templateId": "https://admin-shell.io/idta-02004-2-0" }, - "id": "https://admin-shell.io/idta/SubmodelTemplate/HandoverDocumentation/2/0", + "id": "https://mm-software.com/submodel/000-001/HandoverDocumentation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -9886,7 +9884,7 @@ "text": "The Submodel HierarchicalStructures identified by its semanticId. The Submodel idShort can be picked freely." } ], - "id": "https://admin-shell.io/idta/CustomSubmodel/Template/0/1", + "id": "https://mm-software.com/submodel/000-002/CustomSubmodel", "kind": "Instance", "semanticId": { "type": "ExternalReference", @@ -11483,7 +11481,7 @@ }, { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-002/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -12687,7 +12685,7 @@ "revision": "0", "templateId": "https://admin-shell.io/idta-02004-2-0" }, - "id": "https://admin-shell.io/idta/SubmodelTemplate/HandoverDocumentation/2/0", + "id": "https://mm-software.com/submodel/000-002/HandoverDocumentation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -16355,7 +16353,7 @@ "text": "The Submodel HierarchicalStructures identified by its semanticId. The Submodel idShort can be picked freely." } ], - "id": "https://admin-shell.io/idta/CustomSubmodel/Template/0/1", + "id": "https://mm-software.com/submodel/000-003/CustomSubmodel", "kind": "Instance", "semanticId": { "type": "ExternalReference", @@ -17952,7 +17950,7 @@ }, { "idShort": "ContactInformations", - "id": "https://admin-shell.io/idta/SubmodelTemplate/ContactInformation/1/0", + "id": "https://mm-software.com/submodel/000-003/ContactInformation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -19800,7 +19798,7 @@ "revision": "0", "templateId": "https://admin-shell.io/idta-02004-2-0" }, - "id": "https://admin-shell.io/idta/SubmodelTemplate/HandoverDocumentation/2/0", + "id": "https://mm-software.com/submodel/000-003/HandoverDocumentation", "kind": "Instance", "semanticId": { "type": "ModelReference", @@ -26511,4 +26509,4 @@ "modelType": "Submodel" } ] -} \ No newline at end of file +} diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin/Data/mock-metadata.json b/source/AAS.TwinEngine.Plugin.TestPlugin/Data/mock-metadata.json index 2b414d87..4ed342a3 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin/Data/mock-metadata.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin/Data/mock-metadata.json @@ -1,7 +1,7 @@ [ { "globalAssetId": "https://mm-software.com/ids/assets/000-001", - "idShort": "M&M01", + "idShort": "MM01", "id": "https://mm-software.com/ids/aas/000-001", "specificAssetIds": [ { @@ -22,7 +22,7 @@ }, { "globalAssetId": "https://mm-software.com/ids/assets/000-002", - "idShort": "M&M02", + "idShort": "MM02", "id": "https://mm-software.com/ids/aas/000-002", "specificAssetIds": [ { @@ -44,7 +44,7 @@ }, { "globalAssetId": "https://mm-software.com/ids/assets/000-003", - "idShort": "M&M03", + "idShort": "MM03", "id": "https://mm-software.com/ids/aas/000-003", "specificAssetIds": [ { diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By Asset Id and IdShort - M&M03.bru b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By Asset Id and IdShort - M&M03.bru index 51b26cdd..3c505e3d 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By Asset Id and IdShort - M&M03.bru +++ b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By Asset Id and IdShort - M&M03.bru @@ -1,5 +1,5 @@ meta { - name: Get Shells By Asset Id and IdShort - M&M03 + name: Get Shells By Asset Id and IdShort - MM03 type: http seq: 7 } @@ -23,6 +23,6 @@ settings { } docs { - Retrieves shells filtered by specific asset ID and idShort value "M&M03". + Retrieves shells filtered by specific asset ID and idShort value "MM03". The idShort value is URL-encoded in the collection pre-request script. } diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By IdShort - M&M03.bru b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By IdShort - M&M03.bru index 74826380..895285aa 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By IdShort - M&M03.bru +++ b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/Aas Repository/Get Shells By IdShort - M&M03.bru @@ -1,5 +1,5 @@ meta { - name: Get Shells By IdShort - M&M03 + name: Get Shells By IdShort - MM03 type: http seq: 6 } @@ -22,6 +22,6 @@ settings { } docs { - Retrieves shells filtered by idShort value "M&M03" with no assetIds. + Retrieves shells filtered by idShort value "MM03" with no assetIds. The idShort value is URL-encoded in the collection pre-request script. } diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/collection.bru b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/collection.bru index a71cd421..8d8b6616 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/collection.bru +++ b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/apiCollection/collection.bru @@ -4,7 +4,7 @@ vars:pre-request { submodelIdentifierHandoverDocumentation: https://mm-software.com/submodel/000-001/HandoverDocumentation submodelIdentifierCustomSubmodel: https://mm-software.com/submodel/000-001/CustomSubmodel assetId: {"name":"SerialNumber","value":"SN-1111"} - idShort: M&M03 + idShort: MM03 } script:pre-request { @@ -25,10 +25,5 @@ script:pre-request { const encoded = b64EncodeUnicode(plain); bru.setVar(name, encoded); }); - - const rawIdShort = bru.getCollectionVar('idShort'); - if (rawIdShort) { - bru.setVar('idShort', encodeURIComponent(rawIdShort)); - } } diff --git a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/plugin/mock-metadata.json b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/plugin/mock-metadata.json index 2b414d87..4ed342a3 100644 --- a/source/AAS.TwinEngine.Plugin.TestPlugin/Example/plugin/mock-metadata.json +++ b/source/AAS.TwinEngine.Plugin.TestPlugin/Example/plugin/mock-metadata.json @@ -1,7 +1,7 @@ [ { "globalAssetId": "https://mm-software.com/ids/assets/000-001", - "idShort": "M&M01", + "idShort": "MM01", "id": "https://mm-software.com/ids/aas/000-001", "specificAssetIds": [ { @@ -22,7 +22,7 @@ }, { "globalAssetId": "https://mm-software.com/ids/assets/000-002", - "idShort": "M&M02", + "idShort": "MM02", "id": "https://mm-software.com/ids/aas/000-002", "specificAssetIds": [ { @@ -44,7 +44,7 @@ }, { "globalAssetId": "https://mm-software.com/ids/assets/000-003", - "idShort": "M&M03", + "idShort": "MM03", "id": "https://mm-software.com/ids/aas/000-003", "specificAssetIds": [ {