diff --git a/.claude/commands/review-bot-comments.md b/.claude/commands/review-bot-comments.md index 69a117876..0c20e3a17 100644 --- a/.claude/commands/review-bot-comments.md +++ b/.claude/commands/review-bot-comments.md @@ -13,6 +13,18 @@ Systematically address PR review comments from Copilot, Gemini, and CodeQL. gh api repos/joshsmithxrm/ppds-sdk/pulls/[PR]/comments ``` +### Bot Usernames + +Look for comments from these `user.login` values: + +| Bot | Username | +|-----|----------| +| Gemini | `gemini-code-assist[bot]` | +| Copilot | `Copilot` | +| CodeQL/GHAS | `github-advanced-security[bot]` | + +**Note:** CodeQL and Copilot frequently report **duplicate findings** (same file, same line, same issue). Group comments by file+line to identify duplicates before triaging. + ### 2. Triage Each Comment For each bot comment, determine verdict and rationale: @@ -79,6 +91,7 @@ gh api repos/joshsmithxrm/ppds-sdk/pulls/{pr}/comments \ | Bot Claim | Why It's Often Wrong | |-----------|---------------------| | "Use .Where() instead of foreach+if" | Preference, not correctness | +| "Use .Select() instead of foreach" | Using Select for side effects is an anti-pattern | | "Volatile needed with Interlocked" | Interlocked provides barriers | | "OR should be AND" | Logic may be intentionally inverted (DeMorgan) | | "Static field not thread-safe" | May be set once at startup | diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/BatchingBehaviorTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/BatchingBehaviorTests.cs new file mode 100644 index 000000000..63c647f6f --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/BatchingBehaviorTests.cs @@ -0,0 +1,251 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using PPDS.Dataverse.BulkOperations; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for verifying correct batching behavior in bulk operations. +/// +public class BatchingBehaviorTests : BulkOperationExecutorTestsBase +{ + private const string EntityName = "account"; + + [Theory] + [InlineData(1, 10)] // 1 entity, batch size 10 = 1 batch + [InlineData(10, 10)] // 10 entities, batch size 10 = 1 batch + [InlineData(11, 10)] // 11 entities, batch size 10 = 2 batches + [InlineData(100, 10)] // 100 entities, batch size 10 = 10 batches + [InlineData(105, 10)] // 105 entities, batch size 10 = 11 batches + public async Task CreateMultiple_WithVariousBatchSizes_ProcessesAllEntities(int entityCount, int batchSize) + { + // Arrange + var entities = CreateTestEntities(EntityName, entityCount); + var options = new BulkOperationOptions + { + BatchSize = batchSize, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(entityCount); + result.CreatedIds.Should().HaveCount(entityCount); + } + + [Fact] + public async Task CreateMultiple_WithBatchSizeOne_ProcessesEachEntitySeparately() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + BatchSize = 1, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(5); + } + + [Fact] + public async Task CreateMultiple_WithLargeBatchSize_ProcessesInSingleBatch() + { + // Arrange + var entities = CreateTestEntities(EntityName, 50); + var options = new BulkOperationOptions + { + BatchSize = 1000, // Much larger than entity count + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(50); + } + + [Fact] + public async Task CreateMultiple_ProgressReports_MatchBatching() + { + // Arrange + var entities = CreateTestEntities(EntityName, 25); + var options = new BulkOperationOptions + { + BatchSize = 10, + MaxParallelBatches = 1 // Sequential for deterministic progress + }; + var progress = CreateProgressReporter(); + + // Act + await Executor.CreateMultipleAsync(EntityName, entities, options, progress); + + // Assert - Should have 3 batches: 10, 10, 5 + progress.Reports.Should().HaveCount(3); + progress.Reports[0].Processed.Should().Be(10); + progress.Reports[1].Processed.Should().Be(20); + progress.Reports[2].Processed.Should().Be(25); + } + + [Fact] + public async Task UpdateMultiple_ProgressReports_MatchBatching() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 25)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + BatchSize = 10, + MaxParallelBatches = 1 + }; + var progress = CreateProgressReporter(); + + // Act + await Executor.UpdateMultipleAsync(EntityName, updateEntities, options, progress); + + // Assert - Should have 3 batches: 10, 10, 5 + progress.Reports.Should().HaveCount(3); + progress.LastReport!.Processed.Should().Be(25); + } + + [Fact] + public async Task DeleteMultiple_ProgressReports_MatchBatching() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 25)); + var options = new BulkOperationOptions + { + BatchSize = 10, + MaxParallelBatches = 1 + }; + var progress = CreateProgressReporter(); + + // Act + await Executor.DeleteMultipleAsync(EntityName, createResult.CreatedIds!.ToList(), options, progress); + + // Assert - Should have 3 batches: 10, 10, 5 + progress.Reports.Should().HaveCount(3); + progress.LastReport!.Processed.Should().Be(25); + } + + [Fact] + public async Task ProgressReporter_TracksTotalCorrectly() + { + // Arrange + var entities = CreateTestEntities(EntityName, 100); + var options = new BulkOperationOptions + { + BatchSize = 25, + MaxParallelBatches = 1 + }; + var progress = CreateProgressReporter(); + + // Act + await Executor.CreateMultipleAsync(EntityName, entities, options, progress); + + // Assert + progress.LastReport!.Total.Should().Be(100); + progress.LastReport.Processed.Should().Be(100); + progress.LastReport.PercentComplete.Should().Be(100); + } + + [Theory] + [InlineData(5)] + [InlineData(10)] + [InlineData(50)] + [InlineData(100)] + public async Task DefaultBatchSize_ProcessesAllEntities(int entityCount) + { + // Arrange - Use default options (batch size 100) + var entities = CreateTestEntities(EntityName, entityCount); + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(entityCount); + } + + [Fact] + public async Task BypassCustomLogic_IsApplied() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + BypassCustomLogic = CustomLogicBypass.All, + MaxParallelBatches = 1 + }; + + // Act - Should not throw + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task BypassPowerAutomateFlows_IsApplied() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + BypassPowerAutomateFlows = true, + MaxParallelBatches = 1 + }; + + // Act - Should not throw + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task SuppressDuplicateDetection_IsApplied() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + SuppressDuplicateDetection = true, + MaxParallelBatches = 1 + }; + + // Act - Should not throw + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Tag_IsApplied() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + Tag = "TestBulkOperation", + MaxParallelBatches = 1 + }; + + // Act - Should not throw + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/BulkOperationExecutorTestsBase.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/BulkOperationExecutorTestsBase.cs new file mode 100644 index 000000000..532d924be --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/BulkOperationExecutorTestsBase.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Xrm.Sdk; +using PPDS.Dataverse.BulkOperations; +using PPDS.Dataverse.DependencyInjection; +using PPDS.Dataverse.IntegrationTests.Mocks; +using PPDS.Dataverse.Pooling; +using PPDS.Dataverse.Progress; +using PPDS.Dataverse.Resilience; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Base class for BulkOperationExecutor tests using FakeXrmEasy. +/// Provides pre-configured BulkOperationExecutor with mocked dependencies. +/// +public abstract class BulkOperationExecutorTestsBase : FakeXrmEasyTestsBase +{ + /// + /// The BulkOperationExecutor under test. + /// + protected IBulkOperationExecutor Executor { get; } + + /// + /// The fake connection pool for controlling connection behavior. + /// + protected FakeConnectionPool ConnectionPool { get; } + + /// + /// The fake throttle tracker for controlling throttle behavior. + /// + protected FakeThrottleTracker ThrottleTracker { get; } + + /// + /// The Dataverse options for configuring bulk operation behavior. + /// + protected DataverseOptions Options { get; } + + /// + /// Initializes a new instance with mocked BulkOperationExecutor dependencies. + /// + protected BulkOperationExecutorTestsBase() + { + ConnectionPool = new FakeConnectionPool(Service); + ThrottleTracker = new FakeThrottleTracker(); + Options = new DataverseOptions + { + BulkOperations = new BulkOperationOptions + { + BatchSize = 100, + MaxParallelBatches = 1 // Sequential for deterministic testing + }, + Pool = new ConnectionPoolOptions + { + MaxConnectionRetries = 2 + } + }; + + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(Options); + var logger = NullLogger.Instance; + + Executor = new BulkOperationExecutor( + ConnectionPool, + ThrottleTracker, + optionsWrapper, + logger); + } + + /// + /// Creates a collection of test entities with the specified count. + /// + protected static List CreateTestEntities(string entityName, int count) + { + var entities = new List(); + for (int i = 0; i < count; i++) + { + entities.Add(new Entity(entityName) + { + ["name"] = $"Test Entity {i}" + }); + } + return entities; + } + + /// + /// Creates a collection of test entities with IDs for update/upsert operations. + /// + protected static List CreateTestEntitiesWithIds(string entityName, IEnumerable ids) + { + return ids.Select((id, i) => new Entity(entityName, id) + { + ["name"] = $"Updated Entity {i}" + }).ToList(); + } + + /// + /// Creates a progress tracker for testing progress reporting. + /// + protected static TestProgressReporter CreateProgressReporter() + { + return new TestProgressReporter(); + } + + /// + /// Helper class for capturing progress reports during tests. + /// + protected class TestProgressReporter : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public ProgressSnapshot? LastReport => _reports.Count > 0 ? _reports[^1] : null; + + public void Report(ProgressSnapshot value) + { + _reports.Add(value); + } + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/CreateMultipleTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/CreateMultipleTests.cs new file mode 100644 index 000000000..57c95fd31 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/CreateMultipleTests.cs @@ -0,0 +1,189 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.BulkOperations; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for CreateMultipleAsync using FakeXrmEasy. +/// +public class CreateMultipleTests : BulkOperationExecutorTestsBase +{ + private const string EntityName = "account"; + + [Fact] + public async Task CreateMultipleAsync_WithSingleEntity_CreatesSuccessfully() + { + // Arrange + var entities = CreateTestEntities(EntityName, 1); + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(1); + result.FailureCount.Should().Be(0); + result.CreatedIds.Should().NotBeNull(); + result.CreatedIds.Should().HaveCount(1); + } + + [Fact] + public async Task CreateMultipleAsync_WithMultipleEntities_CreatesAllSuccessfully() + { + // Arrange + var entities = CreateTestEntities(EntityName, 10); + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + result.FailureCount.Should().Be(0); + result.CreatedIds.Should().HaveCount(10); + result.CreatedIds!.Should().OnlyContain(id => id != Guid.Empty); + } + + [Fact] + public async Task CreateMultipleAsync_ReturnsValidIds_ThatCanBeRetrieved() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities); + + // Assert + result.CreatedIds.Should().NotBeNull(); + foreach (var id in result.CreatedIds!) + { + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + retrieved.Should().NotBeNull(); + retrieved.LogicalName.Should().Be(EntityName); + } + } + + [Fact] + public async Task CreateMultipleAsync_WithProgressReporter_ReportsProgress() + { + // Arrange + var entities = CreateTestEntities(EntityName, 50); + var progress = CreateProgressReporter(); + + // Act + await Executor.CreateMultipleAsync(EntityName, entities, progress: progress); + + // Assert + progress.Reports.Should().NotBeEmpty(); + progress.LastReport.Should().NotBeNull(); + progress.LastReport!.Processed.Should().Be(50); + } + + [Fact] + public async Task CreateMultipleAsync_WithCustomBatchSize_RespectsBatchSize() + { + // Arrange + var entities = CreateTestEntities(EntityName, 25); + var options = new BulkOperationOptions + { + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert - 25 entities with batch size 10 = 3 batches (10, 10, 5) + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(25); + } + + [Fact] + public async Task CreateMultipleAsync_WithEmptyCollection_ReturnsEmptyResult() + { + // Arrange + var entities = new List(); + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities); + + // Assert + result.SuccessCount.Should().Be(0); + result.FailureCount.Should().Be(0); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CreateMultipleAsync_PreservesEntityAttributes() + { + // Arrange + var entity = new Entity(EntityName) + { + ["name"] = "Test Account", + ["description"] = "Test Description", + ["revenue"] = new Money(1000m) + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, new[] { entity }); + + // Assert + result.CreatedIds.Should().NotBeNull().And.HaveCount(1); + var retrieved = Service.Retrieve(EntityName, result.CreatedIds![0], new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Test Account"); + retrieved.GetAttributeValue("description").Should().Be("Test Description"); + retrieved.GetAttributeValue("revenue").Value.Should().Be(1000m); + } + + [Fact] + public async Task CreateMultipleAsync_WithCancellationToken_AcceptsToken() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + using var cts = new CancellationTokenSource(); + + // Act - verify the method accepts a cancellation token + var result = await Executor.CreateMultipleAsync(EntityName, entities, cancellationToken: cts.Token); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(5); + } + + [Fact] + public async Task CreateMultipleAsync_WithLargeBatch_ProcessesSuccessfully() + { + // Arrange - 500 entities exceeds default batch size of 100, tests batching + var entities = CreateTestEntities(EntityName, 500); + var options = new BulkOperationOptions + { + BatchSize = 100, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(500); + result.CreatedIds.Should().HaveCount(500); + } + + [Fact] + public async Task CreateMultipleAsync_Duration_IsNonZero() + { + // Arrange + var entities = CreateTestEntities(EntityName, 10); + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities); + + // Assert + result.Duration.Should().BeGreaterThan(TimeSpan.Zero); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/DeleteMultipleTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/DeleteMultipleTests.cs new file mode 100644 index 000000000..c01a2e3c7 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/DeleteMultipleTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.BulkOperations; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for DeleteMultipleAsync using FakeXrmEasy. +/// Note: Standard delete uses ExecuteMultiple which has limited support in FakeXrmEasy. +/// Some tests are skipped or simplified due to FakeXrmEasy limitations. +/// +public class DeleteMultipleTests : BulkOperationExecutorTestsBase +{ + private const string EntityName = "account"; + + [Fact] + public async Task DeleteMultipleAsync_WithEmptyCollection_ReturnsEmptyResult() + { + // Arrange + var ids = new List(); + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, ids); + + // Assert + result.SuccessCount.Should().Be(0); + result.FailureCount.Should().Be(0); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DeleteMultipleAsync_WithCancellationToken_AcceptsToken() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 3)); + var idsToDelete = createResult.CreatedIds!.ToList(); + using var cts = new CancellationTokenSource(); + + // Act - verify the method accepts a cancellation token and processes records + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, cancellationToken: cts.Token); + + // Assert - With FakeXrmEasy ExecuteMultiple, results may vary + result.Should().NotBeNull(); + result.TotalCount.Should().Be(3); + } + + [Fact] + public async Task DeleteMultipleAsync_Duration_IsNonZero() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 3)); + var idsToDelete = createResult.CreatedIds!.ToList(); + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete); + + // Assert - Duration should always be tracked + result.Duration.Should().BeGreaterThan(TimeSpan.Zero); + } + + [Fact] + public async Task DeleteMultipleAsync_WithProgress_ReportsProgress() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var progress = CreateProgressReporter(); + + // Act + await Executor.DeleteMultipleAsync(EntityName, idsToDelete, progress: progress); + + // Assert - Progress should be reported + progress.Reports.Should().NotBeEmpty(); + progress.LastReport.Should().NotBeNull(); + progress.LastReport!.Total.Should().Be(10); + } + + [Fact] + public async Task DeleteMultipleAsync_WithOptions_AcceptsOptions() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions + { + BatchSize = 2, + ContinueOnError = true, + MaxParallelBatches = 1 + }; + + // Act - verify the method accepts options + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert + result.Should().NotBeNull(); + result.TotalCount.Should().Be(5); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/ElasticTableTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/ElasticTableTests.cs new file mode 100644 index 000000000..76cc32f54 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/ElasticTableTests.cs @@ -0,0 +1,493 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.BulkOperations; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for bulk operations with ElasticTable mode enabled. +/// Elastic tables (Cosmos DB-backed) use different APIs and support partial success. +/// +public class ElasticTableTests : BulkOperationExecutorTestsBase +{ + private const string EntityName = "account"; + + #region DeleteMultiple - ElasticTable Mode + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithSingleId_DeletesSuccessfully() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 1)); + var idToDelete = createResult.CreatedIds![0]; + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, new[] { idToDelete }, options); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(1); + result.FailureCount.Should().Be(0); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithMultipleIds_DeletesAllSuccessfully() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + result.FailureCount.Should().Be(0); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_RemovesRecordsFromDatastore() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert - Verify records no longer exist + foreach (var id in idsToDelete) + { + var query = new QueryExpression(EntityName) + { + ColumnSet = new ColumnSet(true), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression(EntityName + "id", ConditionOperator.Equal, id) + } + } + }; + var results = Service.RetrieveMultiple(query); + results.Entities.Should().BeEmpty(); + } + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithProgressReporter_ReportsProgress() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 50)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + var progress = CreateProgressReporter(); + + // Act + await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options, progress); + + // Assert + progress.Reports.Should().NotBeEmpty(); + progress.LastReport.Should().NotBeNull(); + progress.LastReport!.Processed.Should().Be(50); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithCustomBatchSize_RespectsBatchSize() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 25)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions + { + ElasticTable = true, + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(25); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithLargeBatch_ProcessesSuccessfully() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 200)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions + { + ElasticTable = true, + BatchSize = 50, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(200); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_OnlyDeletesSpecifiedRecords() + { + // Arrange - Create 5 entities, delete only 3 + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var allIds = createResult.CreatedIds!.ToList(); + var idsToDelete = allIds.Take(3).ToList(); + var idsToKeep = allIds.Skip(3).ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert - Deleted records should be gone + foreach (var id in idsToDelete) + { + var query = new QueryExpression(EntityName) + { + Criteria = { Conditions = { new ConditionExpression(EntityName + "id", ConditionOperator.Equal, id) } } + }; + Service.RetrieveMultiple(query).Entities.Should().BeEmpty(); + } + + // Assert - Kept records should still exist + foreach (var id in idsToKeep) + { + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + retrieved.Should().NotBeNull(); + } + } + + #endregion + + #region CreateMultiple - ElasticTable Mode + + [Fact] + public async Task CreateMultipleAsync_ElasticTable_CreatesSuccessfully() + { + // Arrange + var entities = CreateTestEntities(EntityName, 10); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + result.CreatedIds.Should().HaveCount(10); + } + + [Fact] + public async Task CreateMultipleAsync_ElasticTable_WithBatching_ProcessesAllBatches() + { + // Arrange + var entities = CreateTestEntities(EntityName, 35); + var options = new BulkOperationOptions + { + ElasticTable = true, + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert - 35 entities with batch size 10 = 4 batches + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(35); + } + + [Fact] + public async Task CreateMultipleAsync_ElasticTable_WithProgress_ReportsCorrectly() + { + // Arrange + var entities = CreateTestEntities(EntityName, 30); + var options = new BulkOperationOptions + { + ElasticTable = true, + BatchSize = 10, + MaxParallelBatches = 1 + }; + var progress = CreateProgressReporter(); + + // Act + await Executor.CreateMultipleAsync(EntityName, entities, options, progress); + + // Assert + progress.Reports.Should().HaveCount(3); // 3 batches of 10 + progress.LastReport!.Processed.Should().Be(30); + progress.LastReport.Total.Should().Be(30); + } + + #endregion + + #region UpdateMultiple - ElasticTable Mode + + [Fact] + public async Task UpdateMultipleAsync_ElasticTable_UpdatesSuccessfully() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + } + + [Fact] + public async Task UpdateMultipleAsync_ElasticTable_PersistsChanges() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 3)); + var updateEntities = createResult.CreatedIds!.Select((id, i) => new Entity(EntityName, id) + { + ["name"] = $"ElasticTable Updated {i}", + ["description"] = "Updated via elastic table mode" + }).ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + for (int i = 0; i < createResult.CreatedIds!.Count; i++) + { + var retrieved = Service.Retrieve(EntityName, createResult.CreatedIds[i], new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be($"ElasticTable Updated {i}"); + retrieved.GetAttributeValue("description").Should().Be("Updated via elastic table mode"); + } + } + + [Fact] + public async Task UpdateMultipleAsync_ElasticTable_WithBatching_ProcessesAllBatches() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 45)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + ElasticTable = true, + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(45); + } + + #endregion + + #region UpsertMultiple - ElasticTable Mode + + [Fact] + public async Task UpsertMultipleAsync_ElasticTable_UpdatesExistingRecords() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var upsertEntities = createResult.CreatedIds!.Select((id, i) => new Entity(EntityName, id) + { + ["name"] = $"ElasticTable Upserted {i}" + }).ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + result.UpdatedCount.Should().Be(10); + } + + [Fact] + public async Task UpsertMultipleAsync_ElasticTable_PersistsChanges() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 3)); + var upsertEntities = createResult.CreatedIds!.Select((id, i) => new Entity(EntityName, id) + { + ["name"] = $"ElasticTable Upsert Result {i}" + }).ToList(); + var options = new BulkOperationOptions { ElasticTable = true }; + + // Act + await Executor.UpsertMultipleAsync(EntityName, upsertEntities, options); + + // Assert + for (int i = 0; i < createResult.CreatedIds!.Count; i++) + { + var retrieved = Service.Retrieve(EntityName, createResult.CreatedIds[i], new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be($"ElasticTable Upsert Result {i}"); + } + } + + [Fact] + public async Task UpsertMultipleAsync_ElasticTable_WithBatching_ProcessesAllBatches() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 55)); + var upsertEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + ElasticTable = true, + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(55); + } + + #endregion + + #region ContinueOnError Behavior + + [Fact] + public async Task CreateMultipleAsync_ElasticTable_WithContinueOnError_AcceptsOption() + { + // Arrange + var entities = CreateTestEntities(EntityName, 10); + var options = new BulkOperationOptions + { + ElasticTable = true, + ContinueOnError = true + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + } + + [Fact] + public async Task UpdateMultipleAsync_ElasticTable_WithContinueOnError_AcceptsOption() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + ElasticTable = true, + ContinueOnError = true + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithContinueOnError_AcceptsOption() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions + { + ElasticTable = true, + ContinueOnError = true + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + } + + #endregion + + #region Bypass Options with ElasticTable + + [Fact] + public async Task CreateMultipleAsync_ElasticTable_WithBypassCustomLogic_AcceptsOption() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + ElasticTable = true, + BypassCustomLogic = CustomLogicBypass.All + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task UpdateMultipleAsync_ElasticTable_WithBypassPowerAutomate_AcceptsOption() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + ElasticTable = true, + BypassPowerAutomateFlows = true + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WithTag_AcceptsOption() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var options = new BulkOperationOptions + { + ElasticTable = true, + Tag = "ElasticTableBulkDelete" + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + #endregion +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/PartialSuccessTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/PartialSuccessTests.cs new file mode 100644 index 000000000..9876fd0b3 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/PartialSuccessTests.cs @@ -0,0 +1,222 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using PPDS.Dataverse.BulkOperations; +using PPDS.Dataverse.IntegrationTests.FakeMessageExecutors; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for partial success scenarios in bulk operations. +/// +/// Note: Full partial success testing (per-record errors with ContinueOnError) requires +/// live Dataverse because the SDK's partial success response format is complex and +/// FakeXrmEasy cannot accurately simulate it. +/// +/// These tests verify: +/// - Error handling when entire batches fail +/// - ContinueOnError option is properly passed +/// - Error details are captured +/// +/// +/// Uses [Collection] to prevent parallel execution since DeleteMultipleRequestExecutor +/// uses a static FailurePredicate that could cause test pollution if run in parallel. +/// +[Collection("FailurePredicate")] +public class PartialSuccessTests : BulkOperationExecutorTestsBase, IDisposable +{ + private const string EntityName = "account"; + + public override void Dispose() + { + // Reset any failure predicates after each test + DeleteMultipleRequestExecutor.ResetFailurePredicate(); + base.Dispose(); + } + + #region Delete with Non-Existent Records (Standard Tables) + + [Fact] + public async Task DeleteMultipleAsync_StandardTable_WithNonExistentId_ReportsFailure() + { + // Arrange - Include a non-existent ID + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 3)); + var idsToDelete = createResult.CreatedIds!.ToList(); + idsToDelete.Add(Guid.NewGuid()); // Non-existent ID + + var options = new BulkOperationOptions + { + ContinueOnError = true, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert - Operation should complete with some failures recorded + result.TotalCount.Should().Be(4); + // Note: Exact success/failure count depends on ExecuteMultiple behavior in FakeXrmEasy + } + + #endregion + + #region Delete with Simulated Failures (Elastic Tables) + + [Fact] + public async Task DeleteMultipleAsync_ElasticTable_WhenRecordFailsToDelete_CapturesError() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var idsToDelete = createResult.CreatedIds!.ToList(); + var failId = idsToDelete[2]; // Third record will fail + + // Configure executor to fail on specific record + DeleteMultipleRequestExecutor.FailurePredicate = entityRef => entityRef.Id == failId; + + var options = new BulkOperationOptions + { + ElasticTable = true, + ContinueOnError = true, + MaxParallelBatches = 1 + }; + + // Act - The executor catches exceptions and returns them as failures + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert - Should report failure + result.TotalCount.Should().Be(5); + result.FailureCount.Should().BeGreaterThan(0); + result.Errors.Should().NotBeEmpty(); + } + + #endregion + + #region Update with Non-Existent Records + + [Fact] + public async Task UpdateMultipleAsync_WithNonExistentId_ReportsFailure() + { + // Arrange - Create some entities and add a fake one + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 2)); + var updateEntities = createResult.CreatedIds!.Select((id, i) => new Entity(EntityName, id) + { + ["name"] = $"Updated {i}" + }).ToList(); + + // Add a non-existent entity + updateEntities.Add(new Entity(EntityName, Guid.NewGuid()) + { + ["name"] = "This should fail" + }); + + var options = new BulkOperationOptions + { + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert - The batch with the non-existent record should fail + result.TotalCount.Should().Be(3); + result.FailureCount.Should().BeGreaterThan(0); + } + + #endregion + + #region Error Details Collection + + [Fact] + public async Task DeleteMultipleAsync_WhenFailure_PopulatesErrorDetails() + { + // Arrange - Delete non-existent record + var nonExistentIds = new List { Guid.NewGuid() }; + var options = new BulkOperationOptions + { + ContinueOnError = true, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, nonExistentIds, options); + + // Assert - Error should be captured + result.FailureCount.Should().Be(1); + result.Errors.Should().NotBeEmpty(); + result.Errors[0].Index.Should().Be(0); + result.Errors[0].Message.Should().NotBeNullOrEmpty(); + } + + #endregion + + #region ContinueOnError Behavior Verification + + [Fact] + public async Task CreateMultipleAsync_ElasticTable_WithDefaultOptions_Succeeds() + { + // Arrange + var entities = CreateTestEntities(EntityName, 5); + var options = new BulkOperationOptions + { + ElasticTable = true + }; + + // Act + var result = await Executor.CreateMultipleAsync(EntityName, entities, options); + + // Assert - Should succeed normally with default options + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task UpdateMultipleAsync_ElasticTable_ContinueOnErrorTrue_ProcessesAll() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + ElasticTable = true, + ContinueOnError = true + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + } + + #endregion + + #region Batch Failure Isolation + + [Fact] + public async Task DeleteMultipleAsync_MultipleBatches_OneFailingBatch_OthersSucceed() + { + // Arrange - 3 batches: 10, 10, 5 records + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 25)); + var idsToDelete = createResult.CreatedIds!.ToList(); + + // Inject a non-existent ID into the second batch (records 10-19) + idsToDelete[15] = Guid.NewGuid(); + + var options = new BulkOperationOptions + { + BatchSize = 10, + ContinueOnError = true, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.DeleteMultipleAsync(EntityName, idsToDelete, options); + + // Assert - Some records should succeed, some should fail + result.TotalCount.Should().Be(25); + // First batch (10) and third batch (5) should succeed + // Second batch behavior depends on ContinueOnError handling + } + + #endregion +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/UpdateMultipleTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/UpdateMultipleTests.cs new file mode 100644 index 000000000..34f1b1062 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/UpdateMultipleTests.cs @@ -0,0 +1,201 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.BulkOperations; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for UpdateMultipleAsync using FakeXrmEasy. +/// +public class UpdateMultipleTests : BulkOperationExecutorTestsBase +{ + private const string EntityName = "account"; + + [Fact] + public async Task UpdateMultipleAsync_WithSingleEntity_UpdatesSuccessfully() + { + // Arrange - Create entities first + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 1)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(1); + result.FailureCount.Should().Be(0); + } + + [Fact] + public async Task UpdateMultipleAsync_WithMultipleEntities_UpdatesAllSuccessfully() + { + // Arrange - Create entities first + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(10); + result.FailureCount.Should().Be(0); + } + + [Fact] + public async Task UpdateMultipleAsync_PersistsChanges() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 3)); + var updateEntities = createResult.CreatedIds!.Select((id, i) => new Entity(EntityName, id) + { + ["name"] = $"Updated Name {i}", + ["description"] = $"Updated Description {i}" + }).ToList(); + + // Act + await Executor.UpdateMultipleAsync(EntityName, updateEntities); + + // Assert - Verify changes were persisted + for (int i = 0; i < createResult.CreatedIds!.Count; i++) + { + var retrieved = Service.Retrieve(EntityName, createResult.CreatedIds[i], new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be($"Updated Name {i}"); + retrieved.GetAttributeValue("description").Should().Be($"Updated Description {i}"); + } + } + + [Fact] + public async Task UpdateMultipleAsync_WithProgressReporter_ReportsProgress() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 50)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var progress = CreateProgressReporter(); + + // Act + await Executor.UpdateMultipleAsync(EntityName, updateEntities, progress: progress); + + // Assert + progress.Reports.Should().NotBeEmpty(); + progress.LastReport.Should().NotBeNull(); + progress.LastReport!.Processed.Should().Be(50); + } + + [Fact] + public async Task UpdateMultipleAsync_WithCustomBatchSize_RespectsBatchSize() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 25)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(25); + } + + [Fact] + public async Task UpdateMultipleAsync_WithEmptyCollection_ReturnsEmptyResult() + { + // Arrange + var entities = new List(); + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, entities); + + // Assert + result.SuccessCount.Should().Be(0); + result.FailureCount.Should().Be(0); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task UpdateMultipleAsync_PreservesUnchangedAttributes() + { + // Arrange - Create with multiple attributes + var createEntity = new Entity(EntityName) + { + ["name"] = "Original Name", + ["description"] = "Original Description", + ["revenue"] = new Money(1000m) + }; + var createResult = await Executor.CreateMultipleAsync(EntityName, new[] { createEntity }); + + // Update only name + var updateEntity = new Entity(EntityName, createResult.CreatedIds![0]) + { + ["name"] = "Updated Name" + }; + + // Act + await Executor.UpdateMultipleAsync(EntityName, new[] { updateEntity }); + + // Assert - Description and revenue should be unchanged + var retrieved = Service.Retrieve(EntityName, createResult.CreatedIds![0], new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Updated Name"); + retrieved.GetAttributeValue("description").Should().Be("Original Description"); + retrieved.GetAttributeValue("revenue").Value.Should().Be(1000m); + } + + [Fact] + public async Task UpdateMultipleAsync_WithCancellationToken_AcceptsToken() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + using var cts = new CancellationTokenSource(); + + // Act - verify the method accepts a cancellation token + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, cancellationToken: cts.Token); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(5); + } + + [Fact] + public async Task UpdateMultipleAsync_WithLargeBatch_ProcessesSuccessfully() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 500)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + BatchSize = 100, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(500); + } + + [Fact] + public async Task UpdateMultipleAsync_Duration_IsNonZero() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var updateEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + + // Act + var result = await Executor.UpdateMultipleAsync(EntityName, updateEntities); + + // Assert + result.Duration.Should().BeGreaterThan(TimeSpan.Zero); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/UpsertMultipleTests.cs b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/UpsertMultipleTests.cs new file mode 100644 index 000000000..1b977d9b7 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/BulkOperations/UpsertMultipleTests.cs @@ -0,0 +1,192 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.BulkOperations; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.BulkOperations; + +/// +/// Tests for UpsertMultipleAsync using FakeXrmEasy. +/// Note: FakeXrmEasy has limitations with upsert on new entities (requires entity metadata). +/// Tests focus on upsert updates of existing records. +/// +public class UpsertMultipleTests : BulkOperationExecutorTestsBase +{ + private const string EntityName = "account"; + + [Fact] + public async Task UpsertMultipleAsync_WithExistingEntities_UpdatesAll() + { + // Arrange - Create entities first then upsert them + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var upsertEntities = createResult.CreatedIds!.Select((id, i) => new Entity(EntityName, id) + { + ["name"] = $"Upserted Entity {i}" + }).ToList(); + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(5); + result.FailureCount.Should().Be(0); + result.UpdatedCount.Should().Be(5); + } + + [Fact] + public async Task UpsertMultipleAsync_WithProgressReporter_ReportsProgress() + { + // Arrange - Use existing entities + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 50)); + var upsertEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var progress = CreateProgressReporter(); + + // Act + await Executor.UpsertMultipleAsync(EntityName, upsertEntities, progress: progress); + + // Assert + progress.Reports.Should().NotBeEmpty(); + progress.LastReport.Should().NotBeNull(); + progress.LastReport!.Processed.Should().Be(50); + } + + [Fact] + public async Task UpsertMultipleAsync_PersistsChanges() + { + // Arrange - Create existing entity + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 1)); + var existingId = createResult.CreatedIds![0]; + + // Upsert with updated values + var upsertEntity = new Entity(EntityName, existingId) + { + ["name"] = "Upserted Name", + ["description"] = "Upserted Description" + }; + + // Act + await Executor.UpsertMultipleAsync(EntityName, new[] { upsertEntity }); + + // Assert + var retrieved = Service.Retrieve(EntityName, existingId, new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Upserted Name"); + retrieved.GetAttributeValue("description").Should().Be("Upserted Description"); + } + + [Fact] + public async Task UpsertMultipleAsync_WithCustomBatchSize_RespectsBatchSize() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 25)); + var upsertEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + BatchSize = 10, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(25); + } + + [Fact] + public async Task UpsertMultipleAsync_WithEmptyCollection_ReturnsEmptyResult() + { + // Arrange + var entities = new List(); + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, entities); + + // Assert + result.SuccessCount.Should().Be(0); + result.FailureCount.Should().Be(0); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task UpsertMultipleAsync_WithCancellationToken_AcceptsToken() + { + // Arrange - Use existing entities + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 5)); + var upsertEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + using var cts = new CancellationTokenSource(); + + // Act - verify the method accepts a cancellation token + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities, cancellationToken: cts.Token); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(5); + } + + [Fact] + public async Task UpsertMultipleAsync_WithLargeBatch_ProcessesSuccessfully() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 200)); + var upsertEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + var options = new BulkOperationOptions + { + BatchSize = 50, + MaxParallelBatches = 1 + }; + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities, options); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.SuccessCount.Should().Be(200); + } + + [Fact] + public async Task UpsertMultipleAsync_Duration_IsNonZero() + { + // Arrange + var createResult = await Executor.CreateMultipleAsync(EntityName, CreateTestEntities(EntityName, 10)); + var upsertEntities = CreateTestEntitiesWithIds(EntityName, createResult.CreatedIds!); + + // Act + var result = await Executor.UpsertMultipleAsync(EntityName, upsertEntities); + + // Assert + result.Duration.Should().BeGreaterThan(TimeSpan.Zero); + } + + [Fact] + public async Task UpsertMultipleAsync_PreservesExistingAttributes() + { + // Arrange - Create entity with multiple attributes + var originalEntity = new Entity(EntityName) + { + ["name"] = "Original Name", + ["description"] = "Original Description", + ["revenue"] = new Money(1000m) + }; + var createResult = await Executor.CreateMultipleAsync(EntityName, new[] { originalEntity }); + var existingId = createResult.CreatedIds![0]; + + // Upsert with only name change + var upsertEntity = new Entity(EntityName, existingId) + { + ["name"] = "Updated Name" + }; + + // Act + await Executor.UpsertMultipleAsync(EntityName, new[] { upsertEntity }); + + // Assert - name changed, other attributes preserved + var retrieved = Service.Retrieve(EntityName, existingId, new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Updated Name"); + retrieved.GetAttributeValue("description").Should().Be("Original Description"); + retrieved.GetAttributeValue("revenue").Value.Should().Be(1000m); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/Client/DataverseClientCrudTests.cs b/tests/PPDS.Dataverse.IntegrationTests/Client/DataverseClientCrudTests.cs new file mode 100644 index 000000000..1d1b0199a --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/Client/DataverseClientCrudTests.cs @@ -0,0 +1,362 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests.Client; + +/// +/// Tests for basic CRUD operations using FakeXrmEasy. +/// These verify the FakePooledClient correctly wraps IOrganizationService operations. +/// +public class DataverseClientCrudTests : FakeXrmEasyTestsBase +{ + private const string EntityName = "account"; + + #region Create Tests + + [Fact] + public void Create_WithValidEntity_ReturnsNonEmptyId() + { + // Arrange + var entity = new Entity(EntityName) + { + ["name"] = "Test Account" + }; + + // Act + var id = Service.Create(entity); + + // Assert + id.Should().NotBeEmpty(); + } + + [Fact] + public void Create_WithMultipleAttributes_PersistsAll() + { + // Arrange + var entity = new Entity(EntityName) + { + ["name"] = "Test Account", + ["description"] = "Test Description", + ["revenue"] = new Money(10000m), + ["numberofemployees"] = 100 + }; + + // Act + var id = Service.Create(entity); + + // Assert + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Test Account"); + retrieved.GetAttributeValue("description").Should().Be("Test Description"); + retrieved.GetAttributeValue("revenue").Value.Should().Be(10000m); + retrieved.GetAttributeValue("numberofemployees").Should().Be(100); + } + + [Fact] + public void Create_WithRelatedEntityReference_PersistsReference() + { + // Arrange - Create a parent first + var parentId = Service.Create(new Entity(EntityName) { ["name"] = "Parent" }); + + var child = new Entity("contact") + { + ["firstname"] = "John", + ["lastname"] = "Doe", + ["parentcustomerid"] = new EntityReference(EntityName, parentId) + }; + + // Act + var childId = Service.Create(child); + + // Assert + var retrieved = Service.Retrieve("contact", childId, new ColumnSet(true)); + var parentRef = retrieved.GetAttributeValue("parentcustomerid"); + parentRef.Should().NotBeNull(); + parentRef.Id.Should().Be(parentId); + } + + #endregion + + #region Retrieve Tests + + [Fact] + public void Retrieve_ExistingEntity_ReturnsEntity() + { + // Arrange + var entity = new Entity(EntityName) { ["name"] = "Test Account" }; + var id = Service.Create(entity); + + // Act + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + + // Assert + retrieved.Should().NotBeNull(); + retrieved.Id.Should().Be(id); + retrieved.LogicalName.Should().Be(EntityName); + } + + [Fact] + public void Retrieve_WithColumnSet_ReturnsOnlyRequestedColumns() + { + // Arrange + var entity = new Entity(EntityName) + { + ["name"] = "Test Account", + ["description"] = "Test Description" + }; + var id = Service.Create(entity); + + // Act + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet("name")); + + // Assert + retrieved.Contains("name").Should().BeTrue(); + // FakeXrmEasy may or may not enforce column filtering - test for expected behavior + } + + [Fact] + public void Retrieve_NonExistentEntity_ThrowsException() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act & Assert + var action = () => Service.Retrieve(EntityName, nonExistentId, new ColumnSet(true)); + action.Should().Throw(); + } + + #endregion + + #region Update Tests + + [Fact] + public void Update_ExistingEntity_ModifiesAttributes() + { + // Arrange + var entity = new Entity(EntityName) { ["name"] = "Original Name" }; + var id = Service.Create(entity); + + var update = new Entity(EntityName, id) { ["name"] = "Updated Name" }; + + // Act + Service.Update(update); + + // Assert + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Updated Name"); + } + + [Fact] + public void Update_PreservesUnchangedAttributes() + { + // Arrange + var entity = new Entity(EntityName) + { + ["name"] = "Original Name", + ["description"] = "Original Description" + }; + var id = Service.Create(entity); + + var update = new Entity(EntityName, id) { ["name"] = "Updated Name" }; + + // Act + Service.Update(update); + + // Assert + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Updated Name"); + retrieved.GetAttributeValue("description").Should().Be("Original Description"); + } + + [Fact] + public void Update_NonExistentEntity_ThrowsException() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + var update = new Entity(EntityName, nonExistentId) { ["name"] = "Updated" }; + + // Act & Assert + var action = () => Service.Update(update); + action.Should().Throw(); + } + + #endregion + + #region Delete Tests + + [Fact] + public void Delete_ExistingEntity_RemovesEntity() + { + // Arrange + var entity = new Entity(EntityName) { ["name"] = "To Delete" }; + var id = Service.Create(entity); + + // Act + Service.Delete(EntityName, id); + + // Assert + var action = () => Service.Retrieve(EntityName, id, new ColumnSet(true)); + action.Should().Throw(); + } + + [Fact] + public void Delete_NonExistentEntity_ThrowsException() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act & Assert + var action = () => Service.Delete(EntityName, nonExistentId); + action.Should().Throw(); + } + + #endregion + + #region RetrieveMultiple Tests + + [Fact] + public void RetrieveMultiple_WithQueryExpression_ReturnsMatchingRecords() + { + // Arrange + Service.Create(new Entity(EntityName) { ["name"] = "Account 1" }); + Service.Create(new Entity(EntityName) { ["name"] = "Account 2" }); + Service.Create(new Entity(EntityName) { ["name"] = "Account 3" }); + + var query = new QueryExpression(EntityName) + { + ColumnSet = new ColumnSet(true) + }; + + // Act + var results = Service.RetrieveMultiple(query); + + // Assert + results.Entities.Should().HaveCount(3); + } + + [Fact] + public void RetrieveMultiple_WithFilter_ReturnsFilteredRecords() + { + // Arrange + Service.Create(new Entity(EntityName) { ["name"] = "Alpha" }); + Service.Create(new Entity(EntityName) { ["name"] = "Beta" }); + Service.Create(new Entity(EntityName) { ["name"] = "Alpha Beta" }); + + var query = new QueryExpression(EntityName) + { + ColumnSet = new ColumnSet(true), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Equal, "Beta") + } + } + }; + + // Act + var results = Service.RetrieveMultiple(query); + + // Assert + results.Entities.Should().HaveCount(1); + results.Entities[0].GetAttributeValue("name").Should().Be("Beta"); + } + + [Fact] + public void RetrieveMultiple_WithNoMatches_ReturnsEmptyCollection() + { + // Arrange + var query = new QueryExpression(EntityName) + { + ColumnSet = new ColumnSet(true), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Equal, "NonExistent") + } + } + }; + + // Act + var results = Service.RetrieveMultiple(query); + + // Assert + results.Entities.Should().BeEmpty(); + } + + [Fact] + public void RetrieveMultiple_WithFetchXml_ReturnsRecords() + { + // Arrange + Service.Create(new Entity(EntityName) { ["name"] = "FetchXml Account" }); + + var fetchXml = $@" + + + + + "; + + // Act + var results = Service.RetrieveMultiple(new FetchExpression(fetchXml)); + + // Assert + results.Entities.Should().HaveCount(1); + } + + [Fact] + public void RetrieveMultiple_WithLikeOperator_ReturnsMatches() + { + // Arrange + Service.Create(new Entity(EntityName) { ["name"] = "Test Company 1" }); + Service.Create(new Entity(EntityName) { ["name"] = "Test Company 2" }); + Service.Create(new Entity(EntityName) { ["name"] = "Other Company" }); + + var query = new QueryExpression(EntityName) + { + ColumnSet = new ColumnSet(true), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Like, "Test%") + } + } + }; + + // Act + var results = Service.RetrieveMultiple(query); + + // Assert + results.Entities.Should().HaveCount(2); + } + + #endregion + + #region Upsert Tests + + [Fact] + public void Execute_UpsertRequest_WithExistingRecord_UpdatesRecord() + { + // Arrange - Create a record first + var originalEntity = new Entity(EntityName) { ["name"] = "Original" }; + var id = Service.Create(originalEntity); + + // Upsert the existing record with new data + var upsertEntity = new Entity(EntityName, id) { ["name"] = "Upserted" }; + var request = new Microsoft.Xrm.Sdk.Messages.UpsertRequest { Target = upsertEntity }; + + // Act + var response = (Microsoft.Xrm.Sdk.Messages.UpsertResponse)Service.Execute(request); + + // Assert + response.RecordCreated.Should().BeFalse(); + var retrieved = Service.Retrieve(EntityName, id, new ColumnSet(true)); + retrieved.GetAttributeValue("name").Should().Be("Upserted"); + } + + #endregion +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/CreateMultipleRequestExecutor.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/CreateMultipleRequestExecutor.cs new file mode 100644 index 000000000..1061a6b8d --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/CreateMultipleRequestExecutor.cs @@ -0,0 +1,36 @@ +using FakeXrmEasy.Abstractions; +using FakeXrmEasy.Abstractions.FakeMessageExecutors; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; + +namespace PPDS.Dataverse.IntegrationTests.FakeMessageExecutors; + +/// +/// FakeXrmEasy message executor for CreateMultipleRequest. +/// Creates each entity in the Targets collection and returns the created IDs. +/// +public class CreateMultipleRequestExecutor : IFakeMessageExecutor +{ + public bool CanExecute(OrganizationRequest request) => request is CreateMultipleRequest; + + public Type GetResponsibleRequestType() => typeof(CreateMultipleRequest); + + public OrganizationResponse Execute(OrganizationRequest request, IXrmFakedContext ctx) + { + var createMultiple = (CreateMultipleRequest)request; + var targets = createMultiple.Targets; + var service = ctx.GetOrganizationService(); + var createdIds = new List(); + + foreach (var entity in targets.Entities) + { + var id = service.Create(entity); + createdIds.Add(id); + } + + return new CreateMultipleResponse + { + Results = { { "Ids", createdIds.ToArray() } } + }; + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/DeleteMultipleRequestExecutor.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/DeleteMultipleRequestExecutor.cs new file mode 100644 index 000000000..d8a772a9b --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/DeleteMultipleRequestExecutor.cs @@ -0,0 +1,59 @@ +using FakeXrmEasy.Abstractions; +using FakeXrmEasy.Abstractions.FakeMessageExecutors; +using Microsoft.Xrm.Sdk; + +namespace PPDS.Dataverse.IntegrationTests.FakeMessageExecutors; + +/// +/// FakeXrmEasy message executor for the DeleteMultiple unbound message. +/// Deletes each entity reference in the Targets collection. +/// Used for elastic table delete operations. +/// +/// +/// DeleteMultiple is an unbound (untyped) Dataverse message, not a typed SDK message class. +/// The request is constructed as: new OrganizationRequest("DeleteMultiple") { Parameters = { { "Targets", entityRefCollection } } } +/// +public class DeleteMultipleRequestExecutor : IFakeMessageExecutor +{ + /// + /// Optional predicate to simulate failures on specific records. + /// If set, records matching this predicate will throw an exception. + /// + public static Func? FailurePredicate { get; set; } + + public bool CanExecute(OrganizationRequest request) => + request.RequestName == "DeleteMultiple"; + + public Type GetResponsibleRequestType() => typeof(OrganizationRequest); + + public OrganizationResponse Execute(OrganizationRequest request, IXrmFakedContext ctx) + { + var targets = request.Parameters["Targets"] as EntityReferenceCollection; + if (targets == null) + { + throw new InvalidOperationException("DeleteMultiple request must have a Targets parameter of type EntityReferenceCollection"); + } + + var service = ctx.GetOrganizationService(); + + foreach (var entityRef in targets) + { + if (FailurePredicate?.Invoke(entityRef) == true) + { + throw new InvalidOperationException($"Simulated failure for {entityRef.LogicalName} {entityRef.Id}"); + } + + service.Delete(entityRef.LogicalName, entityRef.Id); + } + + return new OrganizationResponse { ResponseName = "DeleteMultiple" }; + } + + /// + /// Resets the failure predicate. Call in test cleanup. + /// + public static void ResetFailurePredicate() + { + FailurePredicate = null; + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/UpdateMultipleRequestExecutor.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/UpdateMultipleRequestExecutor.cs new file mode 100644 index 000000000..c4573f4b4 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/UpdateMultipleRequestExecutor.cs @@ -0,0 +1,31 @@ +using FakeXrmEasy.Abstractions; +using FakeXrmEasy.Abstractions.FakeMessageExecutors; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; + +namespace PPDS.Dataverse.IntegrationTests.FakeMessageExecutors; + +/// +/// FakeXrmEasy message executor for UpdateMultipleRequest. +/// Updates each entity in the Targets collection. +/// +public class UpdateMultipleRequestExecutor : IFakeMessageExecutor +{ + public bool CanExecute(OrganizationRequest request) => request is UpdateMultipleRequest; + + public Type GetResponsibleRequestType() => typeof(UpdateMultipleRequest); + + public OrganizationResponse Execute(OrganizationRequest request, IXrmFakedContext ctx) + { + var updateMultiple = (UpdateMultipleRequest)request; + var targets = updateMultiple.Targets; + var service = ctx.GetOrganizationService(); + + foreach (var entity in targets.Entities) + { + service.Update(entity); + } + + return new UpdateMultipleResponse(); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/UpsertMultipleRequestExecutor.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/UpsertMultipleRequestExecutor.cs new file mode 100644 index 000000000..9a3088c1a --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeMessageExecutors/UpsertMultipleRequestExecutor.cs @@ -0,0 +1,38 @@ +using FakeXrmEasy.Abstractions; +using FakeXrmEasy.Abstractions.FakeMessageExecutors; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; + +namespace PPDS.Dataverse.IntegrationTests.FakeMessageExecutors; + +/// +/// FakeXrmEasy message executor for UpsertMultipleRequest. +/// Upserts each entity in the Targets collection - creates if not exists, updates if exists. +/// +public class UpsertMultipleRequestExecutor : IFakeMessageExecutor +{ + public bool CanExecute(OrganizationRequest request) => request is UpsertMultipleRequest; + + public Type GetResponsibleRequestType() => typeof(UpsertMultipleRequest); + + public OrganizationResponse Execute(OrganizationRequest request, IXrmFakedContext ctx) + { + var upsertMultiple = (UpsertMultipleRequest)request; + var targets = upsertMultiple.Targets; + var service = ctx.GetOrganizationService(); + var results = new List(); + + foreach (var entity in targets.Entities) + { + var upsertRequest = new UpsertRequest { Target = entity }; + var upsertResponse = (UpsertResponse)service.Execute(upsertRequest); + results.Add(upsertResponse); + } + + // UpsertMultipleResponse.Results is a direct property that returns the array + // We need to set it via the Parameters collection + var response = new UpsertMultipleResponse(); + response["Results"] = results.ToArray(); + return response; + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs index d017b21fc..b9d4cd48d 100644 --- a/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs @@ -4,6 +4,7 @@ using FakeXrmEasy.Middleware.Crud; using FakeXrmEasy.Middleware.Messages; using Microsoft.Xrm.Sdk; +using PPDS.Dataverse.IntegrationTests.FakeMessageExecutors; namespace PPDS.Dataverse.IntegrationTests; @@ -25,6 +26,8 @@ public abstract class FakeXrmEasyTestsBase : IDisposable /// /// Initializes a new instance of the test base with FakeXrmEasy middleware. + /// Registers custom message executors for bulk operations (CreateMultiple, UpdateMultiple, + /// UpsertMultiple, DeleteMultiple). /// protected FakeXrmEasyTestsBase() { @@ -32,6 +35,10 @@ protected FakeXrmEasyTestsBase() .New() .AddCrud() .AddFakeMessageExecutors() + .AddFakeMessageExecutor(new CreateMultipleRequestExecutor()) + .AddFakeMessageExecutor(new UpdateMultipleRequestExecutor()) + .AddFakeMessageExecutor(new UpsertMultipleRequestExecutor()) + .AddFakeMessageExecutor(new DeleteMultipleRequestExecutor()) .UseCrud() .UseMessages() .SetLicense(FakeXrmEasyLicense.RPL_1_5) diff --git a/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakeConnectionPool.cs b/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakeConnectionPool.cs new file mode 100644 index 000000000..d736161ae --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakeConnectionPool.cs @@ -0,0 +1,88 @@ +using Microsoft.Xrm.Sdk; +using PPDS.Dataverse.Client; +using PPDS.Dataverse.Pooling; + +namespace PPDS.Dataverse.IntegrationTests.Mocks; + +/// +/// Fake IDataverseConnectionPool implementation for testing. +/// Returns FakePooledClient instances wrapping the provided IOrganizationService. +/// +public class FakeConnectionPool : IDataverseConnectionPool +{ + private readonly IOrganizationService _service; + private readonly string _connectionName; + private readonly int _recommendedParallelism; + private int _activeConnections; + + public FakeConnectionPool( + IOrganizationService service, + string connectionName = "fake-connection", + int recommendedParallelism = 4) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _connectionName = connectionName; + _recommendedParallelism = recommendedParallelism; + } + + public bool IsEnabled => true; + public int SourceCount => 1; + + public PoolStatistics Statistics => new() + { + TotalConnections = 10, + ActiveConnections = _activeConnections, + IdleConnections = 10 - _activeConnections, + ThrottledConnections = 0, + RequestsServed = 0, + ThrottleEvents = 0, + InvalidConnections = 0, + AuthFailures = 0, + ConnectionFailures = 0 + }; + + public Task GetClientAsync( + DataverseClientOptions? options = null, + string? excludeConnectionName = null, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _activeConnections); + var client = new FakePooledClient( + _service, + _connectionName, + onDispose: () => Interlocked.Decrement(ref _activeConnections)); + return Task.FromResult(client); + } + + public IPooledClient GetClient(DataverseClientOptions? options = null) + { + Interlocked.Increment(ref _activeConnections); + return new FakePooledClient( + _service, + _connectionName, + onDispose: () => Interlocked.Decrement(ref _activeConnections)); + } + + public async Task TryGetClientWithCapacityAsync(CancellationToken cancellationToken = default) + { + return await GetClientAsync(cancellationToken: cancellationToken); + } + + public Task ExecuteAsync(OrganizationRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(_service.Execute(request)); + } + + public int GetTotalRecommendedParallelism() => _recommendedParallelism; + + public int GetLiveSourceDop(string sourceName) => _recommendedParallelism; + + public int GetActiveConnectionCount(string sourceName) => _activeConnections; + + public void RecordAuthFailure() { } + public void RecordConnectionFailure() { } + public void InvalidateSeed(string connectionName) { } + + public void Dispose() { } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakePooledClient.cs b/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakePooledClient.cs new file mode 100644 index 000000000..54d143ab0 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakePooledClient.cs @@ -0,0 +1,152 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.Client; +using PPDS.Dataverse.Pooling; + +namespace PPDS.Dataverse.IntegrationTests.Mocks; + +/// +/// Fake IPooledClient implementation that wraps a FakeXrmEasy IOrganizationService. +/// Used for testing BulkOperationExecutor with mocked Dataverse operations. +/// +public class FakePooledClient : IPooledClient +{ + private readonly IOrganizationService _service; + private readonly string _connectionName; + private readonly Action? _onDispose; + private bool _isDisposed; + + public FakePooledClient( + IOrganizationService service, + string connectionName = "fake-connection", + Action? onDispose = null) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _connectionName = connectionName; + _onDispose = onDispose; + ConnectionId = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + LastUsedAt = DateTime.UtcNow; + } + + // IPooledClient implementation + public Guid ConnectionId { get; } + public string ConnectionName => _connectionName; + public string DisplayName => $"{_connectionName}@FakeOrg"; + public DateTime CreatedAt { get; } + public DateTime LastUsedAt { get; private set; } + public bool IsInvalid { get; private set; } + public string? InvalidReason { get; private set; } + + public void MarkInvalid(string reason) + { + IsInvalid = true; + InvalidReason = reason; + } + + // IDataverseClient implementation + public bool IsReady => !_isDisposed; + public int RecommendedDegreesOfParallelism => 4; + public Guid? ConnectedOrgId => Guid.NewGuid(); + public string ConnectedOrgFriendlyName => "FakeOrg"; + public string ConnectedOrgUniqueName => "fakeorg"; + public Version? ConnectedOrgVersion => new Version(9, 2, 0, 0); + public string? LastError => null; + public Exception? LastException => null; + public Guid CallerId { get; set; } + public Guid? CallerAADObjectId { get; set; } + public int MaxRetryCount { get; set; } = 3; + public TimeSpan RetryPauseTime { get; set; } = TimeSpan.FromSeconds(2); + + public IDataverseClient Clone() => new FakePooledClient(_service, _connectionName); + + // IOrganizationService implementation - delegate to FakeXrmEasy + public Guid Create(Entity entity) + { + LastUsedAt = DateTime.UtcNow; + return _service.Create(entity); + } + + public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) + { + LastUsedAt = DateTime.UtcNow; + return _service.Retrieve(entityName, id, columnSet); + } + + public void Update(Entity entity) + { + LastUsedAt = DateTime.UtcNow; + _service.Update(entity); + } + + public void Delete(string entityName, Guid id) + { + LastUsedAt = DateTime.UtcNow; + _service.Delete(entityName, id); + } + + public OrganizationResponse Execute(OrganizationRequest request) + { + LastUsedAt = DateTime.UtcNow; + return _service.Execute(request); + } + + public void Associate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) + { + LastUsedAt = DateTime.UtcNow; + _service.Associate(entityName, entityId, relationship, relatedEntities); + } + + public void Disassociate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) + { + LastUsedAt = DateTime.UtcNow; + _service.Disassociate(entityName, entityId, relationship, relatedEntities); + } + + public EntityCollection RetrieveMultiple(QueryBase query) + { + LastUsedAt = DateTime.UtcNow; + return _service.RetrieveMultiple(query); + } + + // IOrganizationServiceAsync2 implementation - wrap sync operations + public Task CreateAsync(Entity entity) => Task.FromResult(Create(entity)); + public Task CreateAsync(Entity entity, CancellationToken cancellationToken) => Task.FromResult(Create(entity)); + public Task CreateAndReturnAsync(Entity entity, CancellationToken cancellationToken = default) + { + var id = Create(entity); + entity.Id = id; + return Task.FromResult(entity); + } + public Task RetrieveAsync(string entityName, Guid id, ColumnSet columnSet) => Task.FromResult(Retrieve(entityName, id, columnSet)); + public Task RetrieveAsync(string entityName, Guid id, ColumnSet columnSet, CancellationToken cancellationToken) => Task.FromResult(Retrieve(entityName, id, columnSet)); + public Task UpdateAsync(Entity entity) { Update(entity); return Task.CompletedTask; } + public Task UpdateAsync(Entity entity, CancellationToken cancellationToken) { Update(entity); return Task.CompletedTask; } + public Task DeleteAsync(string entityName, Guid id) { Delete(entityName, id); return Task.CompletedTask; } + public Task DeleteAsync(string entityName, Guid id, CancellationToken cancellationToken) { Delete(entityName, id); return Task.CompletedTask; } + public Task ExecuteAsync(OrganizationRequest request) => Task.FromResult(Execute(request)); + public Task ExecuteAsync(OrganizationRequest request, CancellationToken cancellationToken) => Task.FromResult(Execute(request)); + public Task AssociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) { Associate(entityName, entityId, relationship, relatedEntities); return Task.CompletedTask; } + public Task AssociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities, CancellationToken cancellationToken) { Associate(entityName, entityId, relationship, relatedEntities); return Task.CompletedTask; } + public Task DisassociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) { Disassociate(entityName, entityId, relationship, relatedEntities); return Task.CompletedTask; } + public Task DisassociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities, CancellationToken cancellationToken) { Disassociate(entityName, entityId, relationship, relatedEntities); return Task.CompletedTask; } + public Task RetrieveMultipleAsync(QueryBase query) => Task.FromResult(RetrieveMultiple(query)); + public Task RetrieveMultipleAsync(QueryBase query, CancellationToken cancellationToken) => Task.FromResult(RetrieveMultiple(query)); + + // IDisposable/IAsyncDisposable + public void Dispose() + { + if (!_isDisposed) + { + _isDisposed = true; + _onDispose?.Invoke(); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakeThrottleTracker.cs b/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakeThrottleTracker.cs new file mode 100644 index 000000000..6aa6fc5b9 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/Mocks/FakeThrottleTracker.cs @@ -0,0 +1,72 @@ +using PPDS.Dataverse.Resilience; + +namespace PPDS.Dataverse.IntegrationTests.Mocks; + +/// +/// Fake IThrottleTracker implementation for testing. +/// By default, never reports throttling. Can be configured to simulate throttling. +/// +public class FakeThrottleTracker : IThrottleTracker +{ + private readonly Dictionary _throttledConnections = new(); + private long _totalThrottleEvents; + + public long TotalThrottleEvents => _totalThrottleEvents; + + public int ThrottledConnectionCount => _throttledConnections.Count(kvp => kvp.Value > DateTime.UtcNow); + + public IReadOnlyCollection ThrottledConnections => + _throttledConnections + .Where(kvp => kvp.Value > DateTime.UtcNow) + .Select(kvp => kvp.Key) + .ToList(); + + public void RecordThrottle(string connectionName, TimeSpan retryAfter) + { + _throttledConnections[connectionName] = DateTime.UtcNow.Add(retryAfter); + Interlocked.Increment(ref _totalThrottleEvents); + } + + public bool IsThrottled(string connectionName) + { + if (_throttledConnections.TryGetValue(connectionName, out var expiry)) + { + return expiry > DateTime.UtcNow; + } + return false; + } + + public DateTime? GetThrottleExpiry(string connectionName) + { + if (_throttledConnections.TryGetValue(connectionName, out var expiry) && expiry > DateTime.UtcNow) + { + return expiry; + } + return null; + } + + public void ClearThrottle(string connectionName) + { + _throttledConnections.Remove(connectionName); + } + + public TimeSpan GetShortestExpiry() + { + var now = DateTime.UtcNow; + var activeThrottles = _throttledConnections + .Where(kvp => kvp.Value > now) + .Select(kvp => kvp.Value - now) + .ToList(); + + return activeThrottles.Count > 0 ? activeThrottles.Min() : TimeSpan.Zero; + } + + /// + /// Resets all throttle state. Useful for test cleanup. + /// + public void Reset() + { + _throttledConnections.Clear(); + _totalThrottleEvents = 0; + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj b/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj index bd18fbfda..26260c9dc 100644 --- a/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj +++ b/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj @@ -21,6 +21,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + +