using WxAgent.Core; using Xunit; namespace WxAgent.Core.Tests; public sealed class RemoteDataSyncTests { [Fact] public void QueuePersistsAuthorizedBatchesAndAcksIdempotently() { var path = Path.Combine(Path.GetTempPath(), "wxagent-sync-" + Guid.NewGuid().ToString("N"), "queue.json"); var config = ConfigFor("chat-a"); var batch = Batch("batch-1", "chat-a"); try { var queue = new RemoteDataBatchQueue(path, maxItems: 2, maxBytes: 1024 * 1024); var accepted = queue.Enqueue(config, batch); Assert.True(accepted.Accepted); Assert.False(accepted.Duplicate); Assert.Equal(1, queue.PendingCount); var reopened = new RemoteDataBatchQueue(path, maxItems: 2, maxBytes: 1024 * 1024); var duplicate = reopened.Enqueue(config, batch); Assert.True(duplicate.Accepted); Assert.True(duplicate.Duplicate); Assert.Equal(1, reopened.PendingCount); var ack = new RemoteDataBatchAck(true, false, batch.BatchId, batch.Sequence, batch.CursorEnd); Assert.True(reopened.MarkConfirmed(ack)); Assert.False(reopened.MarkConfirmed(ack)); Assert.Equal(0, reopened.PendingCount); } finally { var directory = Path.GetDirectoryName(path); if (directory is not null && Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public void QueueDropsUnauthorizedRecordsAndRejectsFullyUnauthorizedBatch() { var config = ConfigFor("chat-a"); var mixed = Batch("mixed", "chat-a") with { Messages = [Message("message-a", "chat-a"), Message("message-b", "chat-b")], }; var filtered = RemoteDataBatchAuthorization.Filter(config, mixed, out var reason); Assert.NotNull(filtered); Assert.Equal("SomeRecordsDroppedByReporting", reason); Assert.Single(filtered!.Messages); Assert.Equal("chat-a", filtered.Messages[0].ChatId); Assert.Equal("partial", filtered.CoverageState); var denied = RemoteDataBatchAuthorization.Filter(config, Batch("denied", "chat-b"), out reason); Assert.Null(denied); Assert.Equal("AllRecordsDroppedByReporting", reason); } [Fact] public void SyncStatePersistsConfirmedCursorAndResetsOnSourceGenerationChange() { var path = Path.Combine(Path.GetTempPath(), "wxagent-sync-state-" + Guid.NewGuid().ToString("N"), "state.json"); try { var state = new RemoteDataSyncStateStore(path); var checkpoint = state.GetLatest("account-a", "messages"); Assert.Equal(0, checkpoint.ConfirmedSequence); var batch = Batch("batch-state", "chat-a") with { CursorStart = "{}", CursorEnd = "{\"chat-a\\u001fmessage/a.db\":7}", Sequence = 1, }; state.MarkCollected(batch, []); state.MarkConfirmed(batch); var reopened = new RemoteDataSyncStateStore(path); var confirmed = reopened.GetLatest("account-a", "messages"); Assert.Equal(1, confirmed.ConfirmedSequence); Assert.Equal(7, confirmed.ConfirmedCursors["chat-a\u001fmessage/a.db"]); var reset = reopened.Get("account-a", "messages", "new-source-generation"); Assert.Equal(0, reset.ConfirmedSequence); Assert.Empty(reset.ConfirmedCursors); } finally { var directory = Path.GetDirectoryName(path); if (directory is not null && Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public void ReconcileResetsLocalCheckpointAfterPlatformRollbackAndDropsPendingBatches() { var statePath = Path.Combine(Path.GetTempPath(), "wxagent-sync-state-" + Guid.NewGuid().ToString("N"), "state.json"); var queuePath = Path.Combine(Path.GetTempPath(), "wxagent-sync-" + Guid.NewGuid().ToString("N"), "queue.json"); try { var config = ConfigFor("chat-a"); var state = new RemoteDataSyncStateStore(statePath); var queue = new RemoteDataBatchQueue(queuePath); var batch = Batch("rollback-batch", "chat-a") with { CursorEnd = "{\"chat-a\\u001fmessage/a.db\":9}" }; state.MarkCollected(batch, []); state.MarkConfirmed(batch); Assert.True(queue.Enqueue(config, batch).Accepted); Assert.True(state.Reconcile(new RemoteDataSyncStatus("unknown", "generation-a", 0, "", null), "account-a", "messages")); Assert.Equal(0, state.GetLatest("account-a", "messages").ConfirmedSequence); Assert.Equal(1, queue.DropForAccount("account-a", "messages")); Assert.Empty(queue.Pending()); } finally { foreach (var path in new[] { statePath, queuePath }) { var directory = Path.GetDirectoryName(path); if (directory is not null && Directory.Exists(directory)) Directory.Delete(directory, true); } } } [Fact] public void QueueReportsBackpressureWithoutDroppingExistingBatches() { var config = ConfigFor("chat-a"); var path = Path.Combine(Path.GetTempPath(), "wxagent-sync-" + Guid.NewGuid().ToString("N"), "queue.json"); var queue = new RemoteDataBatchQueue(path, maxItems: 1, maxBytes: 1024 * 1024); try { Assert.True(queue.Enqueue(config, Batch("batch-1", "chat-a")).Accepted); var rejected = queue.Enqueue(config, Batch("batch-2", "chat-a")); Assert.False(rejected.Accepted); Assert.Equal("SyncQueueFull", rejected.Reason); Assert.Equal(1, queue.PendingCount); } finally { var directory = Path.GetDirectoryName(path); if (directory is not null && Directory.Exists(directory)) Directory.Delete(directory, true); } } private static ReportingConfig ConfigFor(string chatId) => new() { Enabled = true, ConfigVersion = 1, Accounts = [new AccountReportingConfig { AccountId = "account-a", Enabled = true, AllowedChats = [new AllowedChat { Type = ReportingChatType.Private, ChatId = chatId, Enabled = true, IdentityVerified = true }] }] }; private static RemoteSyncBatch Batch(string batchId, string chatId) => new( "node-a", "account-a", batchId, "generation-a", "messages", 1, "0", batchId, "hash-" + batchId, "complete", [], [Message("message-" + batchId, chatId)]); private static RemoteSyncMessage Message(string messageId, string chatId) => new( messageId, chatId, ReportingChatType.Private, "source-" + messageId, "incoming", "text", "hello", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "wx-1", "payload-" + messageId); [Fact] public void ContactBatches_FilterUnauthorizedContactsAndRehash() { var config = ConfigFor("chat-a"); var batch = new RemoteSyncBatch( "node-a", "account-a", "contacts-1", "generation-a", "contacts", 1, "{}", "{}", string.Empty, "complete", [], []) { ContactsSnapshotId = "snapshot-1", Contacts = [ new RemoteSyncContact("chat-a", ReportingChatType.Private, "Allowed", "", DateTimeOffset.UtcNow), new RemoteSyncContact("chat-b", ReportingChatType.Private, "Filtered", "", DateTimeOffset.UtcNow) ] }; var filtered = RemoteDataBatchAuthorization.Filter(config, batch, out var reason); Assert.NotNull(filtered); Assert.Equal("SomeRecordsDroppedByReporting", reason); Assert.Equal("partial", filtered!.CoverageState); Assert.Single(filtered.Contacts); Assert.Equal("chat-a", filtered.Contacts[0].ChatId); Assert.Equal(RemoteDataBatchAuthorization.ComputePayloadHash(filtered), filtered.PayloadHash); } }