//
// 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
}