// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.AirGap.Sync.Models; using StellaOps.AirGap.Sync.Services; using StellaOps.HybridLogicalClock; using StellaOps.TestKit; using Xunit; namespace StellaOps.AirGap.Sync.Tests; /// /// Unit tests for . /// [Trait("Category", TestCategories.Unit)] public sealed class ConflictResolverTests { private readonly ConflictResolver _sut; public ConflictResolverTests() { _sut = new ConflictResolver(NullLogger.Instance); } #region Single Entry Tests [Fact] public void Resolve_SingleEntry_ReturnsDuplicateTimestampWithTakeEarliest() { // Arrange var jobId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var entry = CreateEntry("node-a", 100, 0, jobId); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entry) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert result.Type.Should().Be(ConflictType.DuplicateTimestamp); result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest); result.SelectedEntry.Should().Be(entry); result.DroppedEntries.Should().BeEmpty(); result.Error.Should().BeNull(); } #endregion #region Duplicate Timestamp Tests (Same Payload) [Fact] public void Resolve_TwoEntriesSamePayload_TakesEarliest() { // Arrange var jobId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var payloadHash = CreatePayloadHash(0xAA); var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash); var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHash); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert result.Type.Should().Be(ConflictType.DuplicateTimestamp); result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest); result.SelectedEntry.Should().Be(entryA); result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB); } [Fact] public void Resolve_TwoEntriesSamePayload_TakesEarliest_WhenSecondComesFirst() { // Arrange - Earlier entry is second in list var jobId = Guid.Parse("33333333-3333-3333-3333-333333333333"); var payloadHash = CreatePayloadHash(0xBB); var entryA = CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash); var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash); // Earlier var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert - Should take entryB (earlier) result.Type.Should().Be(ConflictType.DuplicateTimestamp); result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest); result.SelectedEntry.Should().Be(entryB); result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA); } [Fact] public void Resolve_ThreeEntriesSamePayload_TakesEarliestDropsTwo() { // Arrange var jobId = Guid.Parse("44444444-4444-4444-4444-444444444444"); var payloadHash = CreatePayloadHash(0xCC); var entryA = CreateEntryWithPayloadHash("node-a", 150, 0, jobId, payloadHash); var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash); // Earliest var entryC = CreateEntryWithPayloadHash("node-c", 200, 0, jobId, payloadHash); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB), ("node-c", entryC) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert result.Type.Should().Be(ConflictType.DuplicateTimestamp); result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest); result.SelectedEntry.Should().Be(entryB); result.DroppedEntries.Should().HaveCount(2); } [Fact] public void Resolve_SamePhysicalTime_UsesLogicalCounter() { // Arrange var jobId = Guid.Parse("55555555-5555-5555-5555-555555555555"); var payloadHash = CreatePayloadHash(0xDD); var entryA = CreateEntryWithPayloadHash("node-a", 100, 2, jobId, payloadHash); // Higher counter var entryB = CreateEntryWithPayloadHash("node-b", 100, 1, jobId, payloadHash); // Earlier var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert result.SelectedEntry.Should().Be(entryB); // Lower logical counter result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA); } [Fact] public void Resolve_SamePhysicalTimeAndCounter_UsesNodeId() { // Arrange var jobId = Guid.Parse("66666666-6666-6666-6666-666666666666"); var payloadHash = CreatePayloadHash(0xEE); var entryA = CreateEntryWithPayloadHash("alpha-node", 100, 0, jobId, payloadHash); var entryB = CreateEntryWithPayloadHash("beta-node", 100, 0, jobId, payloadHash); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("beta-node", entryB), ("alpha-node", entryA) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert - "alpha-node" < "beta-node" alphabetically result.SelectedEntry.Should().Be(entryA); result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB); } #endregion #region Payload Mismatch Tests [Fact] public void Resolve_DifferentPayloads_ReturnsError() { // Arrange var jobId = Guid.Parse("77777777-7777-7777-7777-777777777777"); var payloadHashA = CreatePayloadHash(0x01); var payloadHashB = CreatePayloadHash(0x02); var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA); var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHashB); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert result.Type.Should().Be(ConflictType.PayloadMismatch); result.Resolution.Should().Be(ResolutionStrategy.Error); result.Error.Should().NotBeNullOrEmpty(); result.Error.Should().Contain(jobId.ToString()); result.Error.Should().Contain("conflicting payloads"); result.SelectedEntry.Should().BeNull(); result.DroppedEntries.Should().BeNull(); } [Fact] public void Resolve_ThreeDifferentPayloads_ReturnsError() { // Arrange var jobId = Guid.Parse("88888888-8888-8888-8888-888888888888"); var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, CreatePayloadHash(0x01)); var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, CreatePayloadHash(0x02)); var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, CreatePayloadHash(0x03)); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB), ("node-c", entryC) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert result.Type.Should().Be(ConflictType.PayloadMismatch); result.Resolution.Should().Be(ResolutionStrategy.Error); } [Fact] public void Resolve_TwoSameOneUnique_ReturnsError() { // Arrange - 2 entries with same payload, 1 with different var jobId = Guid.Parse("99999999-9999-9999-9999-999999999999"); var sharedPayload = CreatePayloadHash(0xAA); var uniquePayload = CreatePayloadHash(0xBB); var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, sharedPayload); var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, sharedPayload); var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, uniquePayload); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)> { ("node-a", entryA), ("node-b", entryB), ("node-c", entryC) }; // Act var result = _sut.Resolve(jobId, conflicting); // Assert - Should be error due to different payloads result.Type.Should().Be(ConflictType.PayloadMismatch); result.Resolution.Should().Be(ResolutionStrategy.Error); } #endregion #region Edge Cases [Fact] public void Resolve_NullConflicting_ThrowsArgumentNullException() { // Arrange var jobId = Guid.NewGuid(); // Act & Assert var act = () => _sut.Resolve(jobId, null!); act.Should().Throw() .WithParameterName("conflicting"); } [Fact] public void Resolve_EmptyConflicting_ThrowsArgumentException() { // Arrange var jobId = Guid.NewGuid(); var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>(); // Act & Assert var act = () => _sut.Resolve(jobId, conflicting); act.Should().Throw() .WithParameterName("conflicting"); } #endregion #region Helper Methods private static byte[] CreatePayloadHash(byte prefix) { var hash = new byte[32]; hash[0] = prefix; return hash; } private static OfflineJobLogEntry CreateEntry(string nodeId, long physicalTime, int logicalCounter, Guid jobId) { var payloadHash = new byte[32]; jobId.ToByteArray().CopyTo(payloadHash, 0); return CreateEntryWithPayloadHash(nodeId, physicalTime, logicalCounter, jobId, payloadHash); } private static OfflineJobLogEntry CreateEntryWithPayloadHash( string nodeId, long physicalTime, int logicalCounter, Guid jobId, byte[] payloadHash) { var hlc = new HlcTimestamp { PhysicalTime = physicalTime, NodeId = nodeId, LogicalCounter = logicalCounter }; return new OfflineJobLogEntry { NodeId = nodeId, THlc = hlc, JobId = jobId, Payload = $"{{\"id\":\"{jobId}\"}}", PayloadHash = payloadHash, Link = new byte[32], EnqueuedAt = DateTimeOffset.UtcNow }; } #endregion }