Harden Surreal migration with retry/coverage fixes and XML docs cleanup
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m17s

This commit is contained in:
Joseph Doherty
2026-02-22 05:39:00 -05:00
parent 9c2a77dc3c
commit bd10914828
27 changed files with 1402 additions and 19 deletions

View File

@@ -0,0 +1,87 @@
using System.Text.Json;
using ZB.MOM.WW.CBDDC.Core.Cache;
using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Core.Tests;
public class DocumentCacheTests
{
/// <summary>
/// Verifies cache hit/miss statistics after get and set operations.
/// </summary>
[Fact]
public async Task GetAndSet_ShouldTrackCacheHitsAndMisses()
{
var cache = new DocumentCache(CreateConfigProvider(maxDocumentCacheSize: 2));
Document? missing = await cache.Get("users", "1");
missing.ShouldBeNull();
var document = CreateDocument("users", "1");
await cache.Set("users", "1", document);
Document? hit = await cache.Get("users", "1");
hit.ShouldNotBeNull();
hit.Key.ShouldBe("1");
var stats = cache.GetStatistics();
stats.Hits.ShouldBe(1);
stats.Misses.ShouldBe(1);
stats.Size.ShouldBe(1);
stats.HitRate.ShouldBe(0.5d);
}
/// <summary>
/// Verifies least-recently-used eviction when cache capacity is reached.
/// </summary>
[Fact]
public async Task Set_WhenCacheIsFull_EvictsLeastRecentlyUsedEntry()
{
var cache = new DocumentCache(CreateConfigProvider(maxDocumentCacheSize: 2));
await cache.Set("users", "1", CreateDocument("users", "1"));
await cache.Set("users", "2", CreateDocument("users", "2"));
// Touch key 1 so key 2 becomes the LRU entry.
(await cache.Get("users", "1")).ShouldNotBeNull();
await cache.Set("users", "3", CreateDocument("users", "3"));
(await cache.Get("users", "2")).ShouldBeNull();
(await cache.Get("users", "1")).ShouldNotBeNull();
(await cache.Get("users", "3")).ShouldNotBeNull();
}
/// <summary>
/// Verifies remove and clear operations delete entries from the cache.
/// </summary>
[Fact]
public async Task RemoveAndClear_ShouldDeleteEntriesFromCache()
{
var cache = new DocumentCache(CreateConfigProvider(maxDocumentCacheSize: 3));
await cache.Set("users", "1", CreateDocument("users", "1"));
await cache.Set("users", "2", CreateDocument("users", "2"));
cache.GetStatistics().Size.ShouldBe(2);
cache.Remove("users", "1");
(await cache.Get("users", "1")).ShouldBeNull();
cache.GetStatistics().Size.ShouldBe(1);
cache.Clear();
cache.GetStatistics().Size.ShouldBe(0);
}
private static Document CreateDocument(string collection, string key)
{
using var json = JsonDocument.Parse("""{"name":"test"}""");
return new Document(collection, key, json.RootElement.Clone(), new HlcTimestamp(1, 0, "node-a"), false);
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(int maxDocumentCacheSize)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
MaxDocumentCacheSize = maxDocumentCacheSize
});
return configProvider;
}
}

View File

@@ -0,0 +1,92 @@
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Sync;
namespace ZB.MOM.WW.CBDDC.Core.Tests;
public class OfflineQueueTests
{
/// <summary>
/// Verifies that enqueuing beyond capacity drops the oldest operation.
/// </summary>
[Fact]
public async Task Enqueue_WhenQueueIsFull_DropsOldestOperation()
{
var queue = new OfflineQueue(CreateConfigProvider(maxQueueSize: 2));
await queue.Enqueue(CreateOperation("1"));
await queue.Enqueue(CreateOperation("2"));
await queue.Enqueue(CreateOperation("3"));
var flushed = new List<string>();
(int successful, int failed) = await queue.FlushAsync(op =>
{
flushed.Add(op.Key);
return Task.CompletedTask;
});
successful.ShouldBe(2);
failed.ShouldBe(0);
flushed.ShouldBe(["2", "3"]);
}
/// <summary>
/// Verifies that flush continues when an executor throws and returns the failure count.
/// </summary>
[Fact]
public async Task FlushAsync_WhenExecutorThrows_ContinuesAndReturnsFailureCount()
{
var queue = new OfflineQueue(CreateConfigProvider(maxQueueSize: 10));
await queue.Enqueue(CreateOperation("1"));
await queue.Enqueue(CreateOperation("2"));
(int successful, int failed) = await queue.FlushAsync(op =>
{
if (op.Key == "1") throw new InvalidOperationException("boom");
return Task.CompletedTask;
});
successful.ShouldBe(1);
failed.ShouldBe(1);
queue.Count.ShouldBe(0);
}
/// <summary>
/// Verifies that clear removes all queued operations.
/// </summary>
[Fact]
public async Task Clear_RemovesAllQueuedOperations()
{
var queue = new OfflineQueue(CreateConfigProvider(maxQueueSize: 10));
await queue.Enqueue(CreateOperation("1"));
await queue.Enqueue(CreateOperation("2"));
queue.Count.ShouldBe(2);
await queue.Clear();
queue.Count.ShouldBe(0);
(int successful, int failed) = await queue.FlushAsync(_ => Task.CompletedTask);
successful.ShouldBe(0);
failed.ShouldBe(0);
}
private static PendingOperation CreateOperation(string key)
{
return new PendingOperation
{
Type = "upsert",
Collection = "users",
Key = key,
Data = new { Value = key },
QueuedAt = DateTime.UtcNow
};
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(int maxQueueSize)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
MaxQueueSize = maxQueueSize
});
return configProvider;
}
}

View File

@@ -0,0 +1,78 @@
using ZB.MOM.WW.CBDDC.Core.Exceptions;
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Resilience;
namespace ZB.MOM.WW.CBDDC.Core.Tests;
public class RetryPolicyTests
{
/// <summary>
/// Verifies transient failures are retried until a successful result is returned.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenTransientFailureEventuallySucceeds_RetriesAndReturnsResult()
{
var policy = new RetryPolicy(CreateConfigProvider(retryAttempts: 3, retryDelayMs: 1));
var attempts = 0;
int result = await policy.ExecuteAsync(async () =>
{
attempts++;
if (attempts < 3) throw new NetworkException("transient");
await Task.CompletedTask;
return 42;
}, "test-op");
result.ShouldBe(42);
attempts.ShouldBe(3);
}
/// <summary>
/// Verifies transient failures throw retry exhausted when all retries are consumed.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenTransientFailureExhausted_ThrowsRetryExhaustedException()
{
var policy = new RetryPolicy(CreateConfigProvider(retryAttempts: 2, retryDelayMs: 1));
var attempts = 0;
var ex = await Should.ThrowAsync<CBDDCException>(() => policy.ExecuteAsync<int>(() =>
{
attempts++;
throw new NetworkException("still transient");
}, "test-op"));
ex.ErrorCode.ShouldBe("RETRY_EXHAUSTED");
ex.InnerException.ShouldBeOfType<NetworkException>();
attempts.ShouldBe(2);
}
/// <summary>
/// Verifies non-transient failures are not retried.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenFailureIsNonTransient_DoesNotRetry()
{
var policy = new RetryPolicy(CreateConfigProvider(retryAttempts: 3, retryDelayMs: 1));
var attempts = 0;
await Should.ThrowAsync<InvalidOperationException>(() => policy.ExecuteAsync<int>(() =>
{
attempts++;
throw new InvalidOperationException("non-transient");
}, "test-op"));
attempts.ShouldBe(1);
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(int retryAttempts, int retryDelayMs)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
RetryAttempts = retryAttempts,
RetryDelayMs = retryDelayMs
});
return configProvider;
}
}