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
+
+