Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Kepware-ConfigAPI-SDK-dotnet.sln
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.3.11505.172 d18.3
VisualStudioVersion = 18.3.11505.172
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kepware.SyncService", "KepwareSync.Service\Kepware.SyncService.csproj", "{19B2841F-01D1-4A7E-BE26-A93CF8C57628}"
EndProject
Expand All @@ -13,7 +13,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionFiles", "SolutionFi
Directory.Build.props = Directory.Build.props
LICENSE.txt = LICENSE.txt
README.md = README.md
version.json = version.json
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kepware.Api", "Kepware.Api\Kepware.Api.csproj", "{ABB368B9-7D95-4946-9579-F7AA7A5750F7}"
Expand Down
116 changes: 116 additions & 0 deletions Kepware.Api.Test/ApiClient/GenericHandleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
using Kepware.Api.Model;
using Kepware.Api.Test.ApiClient;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Contrib.HttpClient;
using Xunit;

namespace Kepware.Api.Test.ApiClient
{
public class GenericHandler : TestApiClientBase
{
[Fact]
public void AppendQueryString_PrivateMethod_EncodesAndSkipsNullsAndAppendsCorrectly()
{
// Arrange
var method = typeof(Kepware.Api.ClientHandler.GenericApiHandler)
.GetMethod("AppendQueryString", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);

var query = new[]
{
new KeyValuePair<string, string?>("a", "b"),
new KeyValuePair<string, string?>("space", "x y"),
new KeyValuePair<string, string?>("skip", null) // should be skipped
};

// Act
var result1 = (string)method!.Invoke(null, new object[] { "https://api/config", query })!;
var result2 = (string)method!.Invoke(null, new object[] { "https://api/config?existing=1", query })!;

// Assert
Assert.Equal("https://api/config?a=b&space=x%20y", result1);
Assert.Equal("https://api/config?existing=1&a=b&space=x%20y", result2);
}

[Fact]
public async Task LoadCollectionAsync_AppendsQueryAndReturnsCollection()
{
// Arrange
var channelsJson = """
[
{
"PROJECT_ID": 676550906,
"common.ALLTYPES_NAME": "Channel1",
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
},
{
"PROJECT_ID": 676550906,
"common.ALLTYPES_NAME": "Data Type Examples",
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
}
]
""";

var query = new[]
{
new KeyValuePair<string, string?>("status", "active"),
new KeyValuePair<string, string?>("name", "John Doe"),
new KeyValuePair<string, string?>("skip", null)
};

// Expect encoded space in "John Doe" and null entry skipped
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels?status=active&name=John%20Doe")
.ReturnsResponse(channelsJson, "application/json");

// Act
var result = await _kepwareApiClient.GenericConfig.LoadCollectionAsync<ChannelCollection, Channel>((string?)null, query);

// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, c => c.Name == "Channel1");
Assert.Contains(result, c => c.Name == "Data Type Examples");
}

[Fact]
public async Task LoadEntityAsync_AppendsQueryAndReturnsEntity()
{
// Arrange
var channelJson = """
{
"PROJECT_ID": 676550906,
"common.ALLTYPES_NAME": "Channel1",
"common.ALLTYPES_DESCRIPTION": "Example Simulator Channel",
"servermain.MULTIPLE_TYPES_DEVICE_DRIVER": "Simulator"
}
""";

var query = new[]
{
new KeyValuePair<string, string?>("status", "active"),
new KeyValuePair<string, string?>("name", "John Doe"),
new KeyValuePair<string, string?>("skip", null)
};

// Expect encoded space in "John Doe" and null entry skipped
_httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels/Channel1?status=active&name=John%20Doe")
.ReturnsResponse(channelJson, "application/json");

// Act
var result = await _kepwareApiClient.GenericConfig.LoadEntityAsync<Channel>("Channel1", query);

// Assert
Assert.NotNull(result);
Assert.Equal("Channel1", result.Name);
Assert.Equal("Example Simulator Channel", result.Description);
}
}
}
2 changes: 1 addition & 1 deletion Kepware.Api.Test/ApiClient/LoadEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ public async Task LoadEntityAsync_ShouldThrowInvalidOperationException_WhenLoadR

#endregion

#region LoadEntityAsync - Single Tag mit DynamicProperties
#region LoadEntityAsync - Single Tag with DynamicProperties

[Fact]
public async Task LoadEntityAsync_ShouldReturnTag_WithCorrectDynamicProperties()
Expand Down
146 changes: 90 additions & 56 deletions Kepware.Api.Test/ApiClient/ProjectLoadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,92 +15,120 @@
using Kepware.Api.Test.ApiClient;
using Kepware.Api.Util;
using Shouldly;
using Xunit.Sdk;

namespace Kepware.Api.Test.ApiClient
{
public class ProjectLoadTests : TestApiClientBase
{

//private async Task ConfigureToServeEndpoints()
//{
// var projectData = await LoadJsonTestDataAsync();

// var channels = projectData.Project?.Channels?.Select(c => new Channel { Name = c.Name, Description = c.Description, DynamicProperties = c.DynamicProperties }).ToList() ?? [];

// // Serve project details
// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project")
// .ReturnsResponse(JsonSerializer.Serialize(new Project { Description = projectData?.Project?.Description, DynamicProperties = projectData?.Project?.DynamicProperties ?? [] }), "application/json");

// // Serve channels without nested devices
// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project/channels")
// .ReturnsResponse(JsonSerializer.Serialize(channels), "application/json");
[Theory]
[InlineData("KEPServerEX", "12", 6, 17, true)]
[InlineData("KEPServerEX", "12", 6, 16, false)]
[InlineData("ThingWorxKepwareEdge", "13", 1, 10, true)]
[InlineData("ThingWorxKepwareEdge", "13", 1, 9, false)]
[InlineData("UnknownProduct", "99", 10, 0, false)]
public async Task LoadProject_ShouldLoadCorrectly_BasedOnProductSupport(
string productName, string productId, int majorVersion, int minorVersion, bool supportsJsonLoad)
{
// This test will validate that the LoadProjectAsync method correctly loads the project structure and
// content based on whether the connected server version supports JsonProjectLoad. It will compare the loaded project against expected test data to ensure accuracy.
// For servers that support JsonProjectLoad, the test will configure the mock server to serve a full JSON project
// and validate that the loaded project matches the test data exactly.

// foreach (var channel in projectData?.Project?.Channels ?? [])
// {
// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + $"/config/v1/project/channels/{channel.Name}")
// .ReturnsResponse(JsonSerializer.Serialize(new Channel { Name = channel.Name, Description = channel.Description, DynamicProperties = channel.DynamicProperties }), "application/json");
// Arrange
ConfigureConnectedClient(productName, productId, majorVersion, minorVersion);

// if (channel.Devices != null)
// {
// var devices = channel.Devices.Select(d => new Device { Name = d.Name, Description = d.Description, DynamicProperties = d.DynamicProperties }).ToList();
// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + $"/config/v1/project/channels/{channel.Name}/devices")
// .ReturnsResponse(JsonSerializer.Serialize(devices), "application/json");
if (supportsJsonLoad)
{
await ConfigureToServeFullProject();
}
else
{
await ConfigureToServeEndpoints();
}

// foreach (var device in channel.Devices)
// {
// var deviceEndpoint = TEST_ENDPOINT + $"/config/v1/project/channels/{channel.Name}/devices/{device.Name}";
// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, deviceEndpoint)
// .ReturnsResponse(JsonSerializer.Serialize(new Device { Name = device.Name, Description = device.Description, DynamicProperties = device.DynamicProperties }), "application/json");
// Act
var project = await _kepwareApiClient.Project.LoadProjectAsync(blnLoadFullProject: true);

// Assert
project.IsLoadedByProjectLoadService.ShouldBe(supportsJsonLoad);

// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, deviceEndpoint + "/tags")
// .ReturnsResponse(JsonSerializer.Serialize(device.Tags), "application/json");
project.ShouldNotBeNull();
project.Channels.ShouldNotBeEmpty("Channels list should not be empty.");

// ConfigureToServeEndpointsTagGroupsRecursive(deviceEndpoint, device.TagGroups ?? []);
// }
// }
// }
//}
var testProject = await LoadJsonTestDataAsync();
var compareResult = EntityCompare.Compare<ChannelCollection, Channel>(testProject?.Project?.Channels, project?.Channels);

//private void ConfigureToServeEndpointsTagGroupsRecursive(string endpoint, IEnumerable<DeviceTagGroup> tagGroups)
//{
// var tagGroupEndpoint = endpoint + "/tag_groups";
compareResult.ShouldNotBeNull();
compareResult.UnchangedItems.ShouldNotBeEmpty("All channels should be unchanged.");
compareResult.ChangedItems.ShouldBeEmpty("No channels should be changed.");
compareResult.ItemsOnlyInLeft.ShouldBeEmpty("No channels should exist only in the test data.");
compareResult.ItemsOnlyInRight.ShouldBeEmpty("No channels should exist only in the loaded project.");

// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, tagGroupEndpoint)
// .ReturnsResponse(JsonSerializer.Serialize(tagGroups), "application/json");
foreach (var (ExpectedChannel, LoadedChannel) in testProject?.Project?.Channels?.Zip(project?.Channels ?? []) ?? [])
{
var deviceCompareResult = EntityCompare.Compare<DeviceCollection, Device>(ExpectedChannel.Devices, LoadedChannel.Devices);
deviceCompareResult.ShouldNotBeNull();
deviceCompareResult.UnchangedItems.ShouldNotBeEmpty($"All devices in channel {ExpectedChannel.Name} should be unchanged.");
deviceCompareResult.ChangedItems.ShouldBeEmpty($"No devices in channel {ExpectedChannel.Name} should be changed.");
deviceCompareResult.ItemsOnlyInLeft.ShouldBeEmpty($"No devices should exist only in the test data for channel {ExpectedChannel.Name}.");
deviceCompareResult.ItemsOnlyInRight.ShouldBeEmpty($"No devices should exist only in the loaded project for channel {ExpectedChannel.Name}.");

// foreach (var tagGroup in tagGroups)
// {
// _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, string.Concat(tagGroupEndpoint, "/", tagGroup.Name, "/tags"))
// .ReturnsResponse(JsonSerializer.Serialize(tagGroup.Tags), "application/json");
foreach (var (ExpectedDevice, LoadedDevice) in ExpectedChannel.Devices?.Zip(LoadedChannel.Devices ?? []) ?? [])
{
if (ExpectedDevice.Tags?.Count > 0 || LoadedDevice.Tags?.Count > 0)
{
var tagCompareResult = EntityCompare.Compare<DeviceTagCollection, Tag>(ExpectedDevice.Tags, LoadedDevice.Tags);
tagCompareResult.ShouldNotBeNull();
tagCompareResult.UnchangedItems.ShouldNotBeEmpty($"All tags in device {ExpectedDevice.Name} should be unchanged.");
tagCompareResult.ChangedItems.ShouldBeEmpty($"No tags in device {ExpectedDevice.Name} should be changed.");
tagCompareResult.ItemsOnlyInLeft.ShouldBeEmpty($"No tags should exist only in the test data for device {ExpectedDevice.Name}.");
tagCompareResult.ItemsOnlyInRight.ShouldBeEmpty($"No tags should exist only in the loaded project for device {ExpectedDevice.Name}.");
}

// ConfigureToServeEndpointsTagGroupsRecursive(string.Concat(tagGroupEndpoint, "/", tagGroup.Name), tagGroup.TagGroups ?? []);
// }
//}
CompareTagGroupsRecursive(ExpectedDevice.TagGroups, LoadedDevice.TagGroups, ExpectedDevice.Name);
}
}
}

[Theory]
[InlineData("KEPServerEX", "12", 6, 17, true)]
[InlineData("KEPServerEX", "12", 6, 16, false)]
[InlineData("ThingWorxKepwareServer", "12", 6, 17, true)]
[InlineData("ThingWorxKepwareEdge", "13", 1, 10, true)]
[InlineData("ThingWorxKepwareEdge", "13", 1, 9, false)]
[InlineData("UnknownProduct", "99", 10, 0, false)]
public async Task LoadProject_ShouldLoadCorrectly_BasedOnProductSupport(
[InlineData("Kepware Edge", "13", 1, 0, true)]
public async Task LoadProject_ShouldLoadCorrectly_Serialize_BasedOnProductSupport(
string productName, string productId, int majorVersion, int minorVersion, bool supportsJsonLoad)
{
// This test will validate that the LoadProjectAsync method correctly loads the project structure using the optimized recursion method.
// It will compare the loaded project against expected test data to ensure accuracy. The test will configure the mock server to serve
// endpoints to support an optimized recursion load and validate that the loaded project matches the test data exactly.

// Arrange
ConfigureConnectedClient(productName, productId, majorVersion, minorVersion);

if (supportsJsonLoad)
{
await ConfigureToServeFullProject();
await ConfigureToServeEndpoints();
}
else
{
await ConfigureToServeEndpoints();
// Skip this test case at runtime because it expects the server to serve a full JSON project.
throw SkipException.ForSkip($"Product {productName} v{majorVersion}.{minorVersion} (id={productId}) does not support JSON project load. Skipping full-project test case.");
}

var project = await _kepwareApiClient.Project.LoadProject(true);
// Override the tag limit to ensure that we are testing the optimized recursion and selectively load objects based on the tag limit.
// See _data/simdemo_en.json and json chunks in _data/projectLoadSerializeData for data that is served by the mock server for this test.
var tagLimitOverride = 100;

project.IsLoadedByProjectLoadService.ShouldBe(supportsJsonLoad);

// Act
var project = await _kepwareApiClient.Project.LoadProjectAsync(blnLoadFullProject: true, projectLoadTagLimit: tagLimitOverride);


// Assert
// Optimized recursion is done for this test, which will result in false.
project.IsLoadedByProjectLoadService.ShouldBeFalse();

project.ShouldNotBeNull();
project.Channels.ShouldNotBeEmpty("Channels list should not be empty.");
Expand Down Expand Up @@ -138,6 +166,12 @@ public async Task LoadProject_ShouldLoadCorrectly_BasedOnProductSupport(
CompareTagGroupsRecursive(ExpectedDevice.TagGroups, LoadedDevice.TagGroups, ExpectedDevice.Name);
}
}

// Verify expected number of calls to the project load endpoints to ensure that the optimized recursion is selectively loading objects based on the tag limit.
foreach (var uri in _optimizedRecursionUris)
{
_httpMessageHandlerMock.VerifyRequest(HttpMethod.Get, uri);
}
}

private static void CompareTagGroupsRecursive(DeviceTagGroupCollection? expected, DeviceTagGroupCollection? actual, string parentName)
Expand Down Expand Up @@ -182,7 +216,7 @@ public async Task LoadProject_NotFull_ShouldLoadCorrectly_BasedOnProductSupport(

await ConfigureToServeEndpoints();

var project = await _kepwareApiClient.Project.LoadProject(blnLoadFullProject: false);
var project = await _kepwareApiClient.Project.LoadProjectAsync(blnLoadFullProject: false);

project.ShouldNotBeNull();
project.Channels.ShouldBeNull("Channels list should be null.");
Expand All @@ -201,7 +235,7 @@ public async Task LoadProject_ShouldReturnEmptyProject_WhenHttpRequestFails()
.ThrowsAsync(new HttpRequestException());

// Act
var project = await _kepwareApiClient.Project.LoadProject(true);
var project = await _kepwareApiClient.Project.LoadProjectAsync(true);

// Assert
project.ShouldNotBeNull();
Expand Down
Loading
Loading