Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
2 changes: 2 additions & 0 deletions src/PPDS.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **PluginRegistrationService refactored to use early-bound entities** - Replaced all magic string attribute access with strongly-typed `PPDS.Dataverse.Generated` classes (`PluginAssembly`, `PluginPackage`, `PluginType`, `SdkMessageProcessingStep`, `SdkMessageProcessingStepImage`, `SdkMessage`, `SdkMessageFilter`, `SystemUser`). Provides compile-time type safety and IntelliSense for all Dataverse entity operations. ([#56](https://github.com/joshsmithxrm/ppds-sdk/issues/56))
- **`PluginRegistrationService` now requires logger** - Constructor now requires `ILogger<PluginRegistrationService>` for diagnostic output. ([#61](https://github.com/joshsmithxrm/ppds-sdk/issues/61))

### Fixed

- **Improved exception handling in `GetComponentTypeAsync`** - Replaced generic catch clause with specific `FaultException<OrganizationServiceFault>` and `FaultException` handlers. Logs failures at Debug level for troubleshooting while maintaining graceful degradation behavior. ([#61](https://github.com/joshsmithxrm/ppds-sdk/issues/61))
- **Environment resolution for service principals** - `ppds env select` now works with full URLs for service principals by trying direct Dataverse connection first, before falling back to Global Discovery (which requires user auth). ([#89](https://github.com/joshsmithxrm/ppds-sdk/issues/89))
- **`auth update --environment` now validates and resolves** - Previously only parsed the URL string without connecting. Now performs full resolution with org metadata population. ([#88](https://github.com/joshsmithxrm/ppds-sdk/issues/88))
- **`env select` validates connection before saving** - Now performs actual WhoAmI request to verify user has access before saving environment selection. Previously resolved metadata but didn't validate access. ([#91](https://github.com/joshsmithxrm/ppds-sdk/issues/91))
Expand Down
4 changes: 3 additions & 1 deletion src/PPDS.Cli/Commands/Plugins/CleanCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PPDS.Cli.Infrastructure;
using PPDS.Cli.Plugins.Models;
using PPDS.Cli.Plugins.Registration;
Expand Down Expand Up @@ -95,8 +96,9 @@ private static async Task<int> ExecuteAsync(
cancellationToken);

var pool = serviceProvider.GetRequiredService<IDataverseConnectionPool>();
var logger = serviceProvider.GetRequiredService<ILogger<PluginRegistrationService>>();
await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken);
var registrationService = new PluginRegistrationService(client);
var registrationService = new PluginRegistrationService(client, logger);

if (outputFormat != OutputFormat.Json)
{
Expand Down
4 changes: 3 additions & 1 deletion src/PPDS.Cli/Commands/Plugins/DeployCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.Json.Serialization;
using System.Xml.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PPDS.Cli.Infrastructure;
using PPDS.Cli.Plugins.Models;
using PPDS.Cli.Plugins.Registration;
Expand Down Expand Up @@ -109,8 +110,9 @@ private static async Task<int> ExecuteAsync(
cancellationToken);

var pool = serviceProvider.GetRequiredService<IDataverseConnectionPool>();
var logger = serviceProvider.GetRequiredService<ILogger<PluginRegistrationService>>();
await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken);
var registrationService = new PluginRegistrationService(client);
var registrationService = new PluginRegistrationService(client, logger);

if (outputFormat != OutputFormat.Json)
{
Expand Down
4 changes: 3 additions & 1 deletion src/PPDS.Cli/Commands/Plugins/DiffCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PPDS.Cli.Infrastructure;
using PPDS.Cli.Plugins.Models;
using PPDS.Cli.Plugins.Registration;
Expand Down Expand Up @@ -86,8 +87,9 @@ private static async Task<int> ExecuteAsync(
cancellationToken);

var pool = serviceProvider.GetRequiredService<IDataverseConnectionPool>();
var logger = serviceProvider.GetRequiredService<ILogger<PluginRegistrationService>>();
await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken);
var registrationService = new PluginRegistrationService(client);
var registrationService = new PluginRegistrationService(client, logger);

if (outputFormat != OutputFormat.Json)
{
Expand Down
4 changes: 3 additions & 1 deletion src/PPDS.Cli/Commands/Plugins/ListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PPDS.Cli.Infrastructure;
using PPDS.Cli.Plugins.Registration;
using PPDS.Dataverse.Pooling;
Expand Down Expand Up @@ -73,8 +74,9 @@ private static async Task<int> ExecuteAsync(
cancellationToken);

var pool = serviceProvider.GetRequiredService<IDataverseConnectionPool>();
var logger = serviceProvider.GetRequiredService<ILogger<PluginRegistrationService>>();
await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken);
var registrationService = new PluginRegistrationService(client);
var registrationService = new PluginRegistrationService(client, logger);

if (outputFormat != OutputFormat.Json)
{
Expand Down
29 changes: 26 additions & 3 deletions src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using System.ServiceModel;
using Microsoft.Crm.Sdk.Messages;
using Microsoft.Extensions.Logging;
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Messages;
Expand All @@ -17,6 +19,7 @@ public sealed class PluginRegistrationService
{
private readonly IOrganizationService _service;
private readonly IOrganizationServiceAsync2? _asyncService;
private readonly ILogger<PluginRegistrationService> _logger;

// Cache for entity type codes (ETCs) - some like pluginpackage vary by environment
private readonly ConcurrentDictionary<string, int> _entityTypeCodeCache = new();
Expand All @@ -42,9 +45,15 @@ public sealed class PluginRegistrationService

#endregion

public PluginRegistrationService(IOrganizationService service)
/// <summary>
/// Creates a new instance of the plugin registration service.
/// </summary>
/// <param name="service">The Dataverse organization service.</param>
/// <param name="logger">Logger for diagnostic output.</param>
public PluginRegistrationService(IOrganizationService service, ILogger<PluginRegistrationService> logger)
{
_service = service;
_logger = logger;
// Use native async when available (ServiceClient implements IOrganizationServiceAsync2)
_asyncService = service as IOrganizationServiceAsync2;
}
Expand Down Expand Up @@ -769,9 +778,23 @@ private async Task<int> GetComponentTypeAsync(string entityLogicalName)
_entityTypeCodeCache[entityLogicalName] = objectTypeCode;
return objectTypeCode;
}
catch
catch (FaultException<OrganizationServiceFault> ex)
{
// Entity doesn't exist or user lacks metadata read permissions - skip solution addition
_logger.LogDebug(
"Could not retrieve component type for entity '{EntityLogicalName}': {ErrorMessage} (ErrorCode: {ErrorCode})",
entityLogicalName,
ex.Detail?.Message ?? ex.Message,
ex.Detail?.ErrorCode);
return 0;
}
catch (FaultException ex)
{
// Entity doesn't exist or can't be queried - return 0 to skip solution addition
// Generic SOAP fault - entity may not exist in this environment
_logger.LogDebug(
"Could not retrieve component type for entity '{EntityLogicalName}': {ErrorMessage}",
entityLogicalName,
ex.Message);
return 0;
}
Comment thread
joshsmithxrm marked this conversation as resolved.
}
Expand Down
1 change: 1 addition & 0 deletions tests/PPDS.Cli.Tests/PPDS.Cli.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.*" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using Microsoft.Extensions.Logging;
using Microsoft.Xrm.Sdk;
using Moq;
using PPDS.Cli.Plugins.Registration;
using Xunit;

namespace PPDS.Cli.Tests.Plugins.Registration;

public class PluginRegistrationServiceTests
{
private readonly Mock<IOrganizationService> _mockService;
private readonly Mock<ILogger<PluginRegistrationService>> _mockLogger;
private readonly PluginRegistrationService _sut;

public PluginRegistrationServiceTests()
{
_mockService = new Mock<IOrganizationService>();
_mockLogger = new Mock<ILogger<PluginRegistrationService>>();
_sut = new PluginRegistrationService(_mockService.Object, _mockLogger.Object);
}

[Fact]
public async Task ListAssembliesAsync_ReturnsEmptyList_WhenNoAssembliesExist()
{
// Arrange
_mockService
.Setup(s => s.RetrieveMultiple(It.IsAny<Microsoft.Xrm.Sdk.Query.QueryExpression>()))
.Returns(new EntityCollection());

// Act
var result = await _sut.ListAssembliesAsync();

// Assert
Assert.Empty(result);
}

[Fact]
public async Task ListAssembliesAsync_ReturnsAssemblies_WhenTheyExist()
{
// Arrange
var entities = new EntityCollection();
var assembly = new Entity("pluginassembly", Guid.NewGuid())
{
["name"] = "TestAssembly",
["version"] = "1.0.0.0",
["publickeytoken"] = "abc123",
["isolationmode"] = new OptionSetValue(2)
};
Comment thread
joshsmithxrm marked this conversation as resolved.
Outdated
entities.Entities.Add(assembly);

_mockService
.Setup(s => s.RetrieveMultiple(It.IsAny<Microsoft.Xrm.Sdk.Query.QueryExpression>()))
.Returns(entities);

// Act
var result = await _sut.ListAssembliesAsync();

// Assert
Assert.Single(result);
Assert.Equal("TestAssembly", result[0].Name);
Assert.Equal("1.0.0.0", result[0].Version);
}

[Fact]
public async Task UpsertAssemblyAsync_CreatesNewAssembly_WhenNotExists()
{
// Arrange
var expectedId = Guid.NewGuid();
_mockService
.Setup(s => s.RetrieveMultiple(It.IsAny<Microsoft.Xrm.Sdk.Query.QueryExpression>()))
.Returns(new EntityCollection());
_mockService
.Setup(s => s.Create(It.IsAny<Entity>()))
.Returns(expectedId);

// Act
var result = await _sut.UpsertAssemblyAsync("TestAssembly", new byte[] { 1, 2, 3 });

// Assert
Assert.Equal(expectedId, result);
_mockService.Verify(s => s.Create(It.Is<Entity>(e => e.LogicalName == "pluginassembly")), Times.Once);
}

[Fact]
public async Task UpsertAssemblyAsync_UpdatesExisting_WhenAssemblyExists()
{
// Arrange
var existingId = Guid.NewGuid();
var entities = new EntityCollection();
entities.Entities.Add(new Entity("pluginassembly", existingId)
{
["name"] = "TestAssembly",
["version"] = "1.0.0.0"
});

_mockService
.Setup(s => s.RetrieveMultiple(It.IsAny<Microsoft.Xrm.Sdk.Query.QueryExpression>()))
.Returns(entities);

// Act
var result = await _sut.UpsertAssemblyAsync("TestAssembly", new byte[] { 1, 2, 3 });

// Assert
Assert.Equal(existingId, result);
_mockService.Verify(s => s.Update(It.Is<Entity>(e => e.Id == existingId)), Times.Once);
_mockService.Verify(s => s.Create(It.IsAny<Entity>()), Times.Never);
}

[Fact]
public async Task GetSdkMessageIdAsync_ReturnsNull_WhenMessageNotFound()
{
// Arrange
_mockService
.Setup(s => s.RetrieveMultiple(It.IsAny<Microsoft.Xrm.Sdk.Query.QueryExpression>()))
.Returns(new EntityCollection());

// Act
var result = await _sut.GetSdkMessageIdAsync("NonExistentMessage");

// Assert
Assert.Null(result);
}

[Fact]
public async Task GetSdkMessageIdAsync_ReturnsId_WhenMessageExists()
{
// Arrange
var messageId = Guid.NewGuid();
var entities = new EntityCollection();
entities.Entities.Add(new Entity("sdkmessage", messageId));

_mockService
.Setup(s => s.RetrieveMultiple(It.IsAny<Microsoft.Xrm.Sdk.Query.QueryExpression>()))
.Returns(entities);

// Act
var result = await _sut.GetSdkMessageIdAsync("Create");

// Assert
Assert.Equal(messageId, result);
}
}
Comment thread
joshsmithxrm marked this conversation as resolved.
Comment thread
joshsmithxrm marked this conversation as resolved.
Loading