refactor: split node agent and Go control plane

This commit is contained in:
2026-09-11 16:57:31 +08:00
parent 078c22c73b
commit c86ba9c4d7
129 changed files with 654 additions and 77 deletions
@@ -0,0 +1,33 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class AccountBindingMatcherTests
{
[Fact]
public void PrefersExactWechatIdOverNickname()
{
var result = AccountBindingMatcher.Match(new UiAccountIdentity("wxid-a", "Same"),
[new("db-1", "wxid-a", "Other"), new("db-2", "wxid-b", "Same")]);
Assert.True(result.IsMatched); Assert.Equal("db-1", result.AccountId); Assert.Equal("wechatId", result.Strategy);
}
[Fact]
public void UsesUniqueNicknameOnlyWhenWechatIdIsUnavailable()
{
var result = AccountBindingMatcher.Match(new UiAccountIdentity(null, " Alice "),
[new("db-1", null, "Alice"), new("db-2", null, "Bob")]);
Assert.True(result.IsMatched); Assert.Equal("db-1", result.AccountId); Assert.Equal("nickname", result.Strategy);
}
[Fact]
public void RejectsDuplicateNicknameAndConflictingDuplicateWechatId()
{
var duplicateName = AccountBindingMatcher.Match(new UiAccountIdentity(null, "Alice"),
[new("db-1", null, "Alice"), new("db-2", null, "Alice")]);
var duplicateId = AccountBindingMatcher.Match(new UiAccountIdentity("wxid-a", "Alice"),
[new("db-1", "wxid-a", "One"), new("db-2", "wxid-a", "Two")]);
Assert.False(duplicateName.IsMatched); Assert.False(duplicateId.IsMatched);
}
}
@@ -0,0 +1,321 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WcdbKeyCandidateScannerTests
{
[Fact]
public void ParsesChunkSplitsLengthsDuplicatesAndBoundaries()
{
var key = string.Concat(Enumerable.Repeat("01", 32));
var salt = string.Concat(Enumerable.Repeat("ab", 16));
var longRun = key + "cafe" + salt;
var bytes = System.Text.Encoding.ASCII.GetBytes("z" + new string('f', 63) + "!" + key + " " + key + salt + "-" + longRun + ".");
var scanner = new WcdbKeyCandidateScanner();
var found = new List<WcdbKeyCandidate>();
found.AddRange(scanner.Feed(bytes.AsSpan(0, 31)));
found.AddRange(scanner.Feed(bytes.AsSpan(31, 80)));
found.AddRange(scanner.Feed(bytes.AsSpan(111), finalBlock: true));
Assert.Equal(2, found.Count);
Assert.Contains(found, candidate => candidate.EncKey == key && candidate.SaltHint is null);
Assert.Single(found, candidate => candidate.EncKey == key && candidate.SaltHint == salt);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
[InlineData(127)]
public void ParsesUtf16AcrossOddChunkBoundaries(int chunkSize)
{
var key = new string('a', 64);
var salt = new string('b', 32);
var bytes = System.Text.Encoding.Unicode.GetBytes("x'" + key + salt + "' " + key + salt);
var scanner = new WcdbKeyCandidateScanner(utf16: true);
var found = new List<WcdbKeyCandidate>();
for (var offset = 0; offset < bytes.Length; offset += chunkSize)
found.AddRange(scanner.Feed(bytes.AsSpan(offset, Math.Min(chunkSize, bytes.Length - offset))));
found.AddRange(scanner.Feed([], finalBlock: true));
Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(found));
}
[Fact]
public void Utf16GapsDoNotJoinFragments()
{
var scanner = new WcdbKeyCandidateScanner(utf16: true);
Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 40))));
Assert.Empty(scanner.Feed([0x61], finalBlock: true));
Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 24)), finalBlock: true));
}
[Fact]
public void BoundsLongRunsAndDoesNotJoinAcrossExplicitGaps()
{
var key = string.Concat(Enumerable.Repeat("01", 32));
var salt = string.Concat(Enumerable.Repeat("ab", 16));
var scanner = new WcdbKeyCandidateScanner();
var longRun = System.Text.Encoding.ASCII.GetBytes(key + new string('c', 1_000_000) + salt);
var found = new List<WcdbKeyCandidate>();
for (var offset = 0; offset < longRun.Length; offset += 4093)
{
found.AddRange(scanner.Feed(longRun.AsSpan(offset, Math.Min(4093, longRun.Length - offset))));
}
found.AddRange(scanner.Feed([], finalBlock: true));
var candidate = Assert.Single(found);
Assert.Equal(key, candidate.EncKey);
Assert.Equal(salt, candidate.SaltHint);
var gapScanner = new WcdbKeyCandidateScanner();
Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[..40])));
Assert.Empty(gapScanner.Feed([], finalBlock: true));
Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[40..]), finalBlock: true));
}
}
public sealed class SqlCipherPageVerifierTests
{
private const string ExpectedMac = "61b73efa9571819b9efd9a8424bd0e3882bb2c35baf5603262a05c630fab78a7c35ffc4041b589a09c9ed8df25457a131608bbfe67af87ebcba5268c8ddee055";
[Fact]
public void MatchesIndependentKnownAnswerAndRejectsMutations()
{
var key = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray();
var page = CreateKnownAnswerPage();
Assert.True(SqlCipherPageVerifier.Verify(page, key));
Assert.True(SqlCipherPageVerifier.VerifyHexKey(page, Convert.ToHexString(key)));
AssertRejected(page, key, 0);
AssertRejected(page, key, 16);
AssertRejected(page, key, page.Length - 1);
var wrongKey = key.ToArray();
wrongKey[0] ^= 1;
Assert.False(SqlCipherPageVerifier.Verify(page, wrongKey));
Assert.False(SqlCipherPageVerifier.Verify(page, key, 2));
}
private static byte[] CreateKnownAnswerPage()
{
var page = new byte[SqlCipherPageVerifier.PageSize];
for (var index = 0; index < SqlCipherPageVerifier.SaltSize; index++)
{
page[index] = (byte)(index + 16);
}
for (var index = SqlCipherPageVerifier.SaltSize; index < page.Length - SqlCipherPageVerifier.HmacSize; index++)
{
page[index] = (byte)(index * 31 + 7);
}
Convert.FromHexString(ExpectedMac).CopyTo(page, page.Length - SqlCipherPageVerifier.HmacSize);
return page;
}
private static void AssertRejected(byte[] page, byte[] key, int index)
{
var changed = page.ToArray();
changed[index] ^= 1;
Assert.False(SqlCipherPageVerifier.Verify(changed, key));
}
}
public sealed class ChatMessageTests
{
[Fact]
public void ParsesDuplicateVisibleMessagesWithStableDistinctFingerprints()
{
var first = VisibleMessageParser.Parse(["same", "same", "other"]);
var second = VisibleMessageParser.Parse(["same", "same", "other"]);
Assert.Equal(3, first.Count);
Assert.Equal(3, first.Select(message => message.Fingerprint).Distinct().Count());
Assert.Equal(first.Select(message => message.Fingerprint), second.Select(message => message.Fingerprint));
}
[Fact]
public void ClassifiesBasicVisibleMessageTypes()
{
Assert.Equal(ChatMessageType.Text, VisibleMessageParser.Classify("hello"));
Assert.Equal(ChatMessageType.Image, VisibleMessageParser.Classify("[图片]"));
Assert.Equal(ChatMessageType.File, VisibleMessageParser.Classify("report.pdf"));
Assert.Equal(ChatMessageType.Video, VisibleMessageParser.Classify("[视频]"));
Assert.Equal(ChatMessageType.Voice, VisibleMessageParser.Classify("语音\n3秒"));
Assert.Equal(ChatMessageType.Link, VisibleMessageParser.Classify("https://example.com"));
Assert.Equal(ChatMessageType.System, VisibleMessageParser.Classify("[系统消息]"));
}
[Fact]
public void ParsesQuotedReplyAndKeepsFingerprintStableAcrossRuntimeIdChanges()
{
var parsed = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-42")]);
var repeated = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-99")]);
var message = Assert.Single(parsed);
Assert.Equal("reply text", message.Text);
Assert.Equal("Alice", message.Quote?.Sender);
Assert.Equal("original text", message.Quote?.Text);
Assert.Equal(message.Fingerprint, Assert.Single(repeated).Fingerprint);
var actualWechat = Assert.Single(VisibleMessageParser.Parse([
new ChatMessageSource("reply text\n引用 Rogee 的消息 : 图片", "runtime-43")
]));
Assert.Equal("reply text", actualWechat.Text);
Assert.Equal("Rogee", actualWechat.Quote?.Sender);
Assert.Equal("图片", actualWechat.Quote?.Text);
Assert.Equal(ChatMessageType.Quote, actualWechat.Type);
var duplicates = VisibleMessageParser.Parse([new ChatMessageSource("same"), new ChatMessageSource("same")]);
Assert.NotEqual(duplicates[0].Fingerprint, duplicates[1].Fingerprint);
}
[Fact]
public void DeduperIsBounded()
{
var deduper = new BoundedMessageDeduper(2);
Assert.True(deduper.TryAdd("a"));
Assert.False(deduper.TryAdd("a"));
Assert.True(deduper.TryAdd("b"));
Assert.True(deduper.TryAdd("c"));
Assert.True(deduper.TryAdd("a"));
}
}
public sealed class MessageEventTests
{
[Fact]
public void CheckpointSuppressesDuplicatesAndRecoversUnseenMessages()
{
var oldMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("old", "runtime-old")]));
var newMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("new", "runtime-new")]));
var initial = new MessageEventState(4);
Assert.NotNull(initial.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, false));
var checkpoint = initial.CreateCheckpoint("文件传输助手", DateTimeOffset.UnixEpoch);
Assert.Equal(2, checkpoint.Version);
var resumed = new MessageEventState(4, checkpoint);
Assert.Null(resumed.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, true));
var recovered = resumed.TryCreateMessage("文件传输助手", newMessage, DateTimeOffset.UnixEpoch, true);
Assert.NotNull(recovered);
Assert.True(recovered!.Recovered);
}
[Fact]
public async Task CallbackFailureDoesNotStopLaterCallbacks()
{
var called = false;
var messageEvent = new MessageEvent("id", MessageEventKind.Reconnected, "文件传输助手", null, DateTimeOffset.UnixEpoch, true);
var failures = await MessageCallbackDispatcher.DispatchAsync(
[
(_, _) => throw new InvalidOperationException("boom"),
(_, _) => { called = true; return Task.CompletedTask; }
],
messageEvent,
CancellationToken.None);
Assert.True(called);
Assert.Single(failures);
Assert.Equal(0, failures[0].CallbackIndex);
}
}
public sealed class SessionTests
{
[Fact]
public void ParsesVisibleSessionsAndMarksCurrent()
{
var sessions = WechatSessionParser.Parse(
[("session_item_文件传输助手", "文件传输助手"), ("noise", "ignored"), ("session_item_文件传输助手", "duplicate")],
"文件传输助手");
var session = Assert.Single(sessions);
Assert.True(session.IsCurrent);
Assert.Equal("文件传输助手", session.Name);
}
[Fact]
public void ParsesSearchResultsAndMarksOnlyExactLines()
{
var results = WechatSessionParser.ParseSearchResults(
[("search_item_function_文件传输助手", "功能\n文件传输助手"), ("search_item_friend_1", "联系人\n文件传输助手测试")],
"文件传输助手");
Assert.Equal(2, results.Count);
var exact = Assert.Single(results, result => result.IsExactMatch);
Assert.Equal("文件传输助手", exact.Name);
Assert.Equal("功能", exact.Category);
}
}
public sealed class IdentityAndSanitizationTests
{
[Fact]
public void AccountFingerprintUsesStableWindowsPathNormalization()
{
Assert.Equal(
AccountRootFingerprint.Create(@"c:/Users/Rogee/Documents/xwechat_files/account/db_storage/"),
AccountRootFingerprint.Create(@"C:\Users\Rogee\Documents\xwechat_files\account\db_storage"));
Assert.NotEqual(AccountRootFingerprint.Create(@"C:\one"), AccountRootFingerprint.Create(@"C:\two"));
}
[Fact]
public void SnapshotSanitizerKeepsStructureAndRedactsContent()
{
Assert.Equal("session_list", UiSnapshotSanitizer.SanitizeAutomationId("session_list"));
Assert.NotEqual("发送", UiSnapshotSanitizer.SanitizeName("发送"));
Assert.StartsWith("[text:2:", UiSnapshotSanitizer.SanitizeName("发送"));
Assert.DoesNotContain("Alice", UiSnapshotSanitizer.SanitizeAutomationId("session_item_Alice"));
Assert.DoesNotContain("secret message", UiSnapshotSanitizer.SanitizeName("secret message"));
}
[Fact]
public void ClassifiesLoggedOutAndChangedUiWithoutGuessingActiveAccount()
{
var controls = WechatLocators.RequiredAutomationIds.ToDictionary(id => id, _ => false);
Assert.Equal(WxAgentErrorCode.WechatNotLoggedIn, WechatLocators.ClassifyFoundWindow(controls));
controls[WechatLocators.MainView] = true;
Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls));
controls[WechatLocators.SessionList] = true;
Assert.Null(WechatLocators.ClassifyFoundWindow(controls));
controls[WechatLocators.ChatPage] = true;
Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls));
foreach (var id in controls.Keys.ToArray())
{
controls[id] = true;
}
Assert.Null(WechatLocators.ClassifyFoundWindow(controls));
}
[Fact]
public void KeyStoreMergePreservesHistoricalAccountsAndDatabases()
{
var now = DateTimeOffset.UtcNow;
var oldDatabase = new DatabaseKeyEvidence("message/msg_0.db", "00", new string('1', 64), 1, now.AddDays(-1), KeyBindingConfidence.PageHmacVerified);
var newDatabase = new DatabaseKeyEvidence("contact/contact.db", "11", new string('2', 64), 2, now, KeyBindingConfidence.PageHmacVerified);
var existing = new[]
{
new AccountKeySet("account-a", @"C:\\a", "4.1.0", now.AddDays(-1), [oldDatabase]),
new AccountKeySet("account-history", @"C:\\history", "4.0.0", now.AddDays(-2), [])
};
var incoming = new[]
{
new AccountKeySet("account-a", @"C:\\a", "4.1.8", now, [newDatabase])
};
var merged = AccountKeySetMerge.Merge(existing, incoming);
Assert.Equal(2, merged.Count);
var account = Assert.Single(merged, item => item.AccountRootFingerprint == "account-a");
Assert.Equal("4.1.8", account.WechatVersion);
Assert.Equal(2, account.Databases.Count);
Assert.Contains(merged, item => item.AccountRootFingerprint == "account-history");
}
}
@@ -0,0 +1,63 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class InterprocessCommandGateTests
{
[Fact]
public async Task SeparateInstancesShareAnExclusiveLease()
{
var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock");
try
{
using var first = new InterprocessCommandGate(path);
using var second = new InterprocessCommandGate(path);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await first.WaitAsync(timeout.Token);
var waiting = second.WaitAsync(timeout.Token);
Assert.False(waiting.IsCompleted);
first.Release();
await waiting;
second.Release();
}
finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); }
}
[Fact]
public async Task CancelledContentionDoesNotPoisonTheLocalQueue()
{
var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock");
try
{
using var first = new InterprocessCommandGate(path);
using var second = new InterprocessCommandGate(path);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await first.WaitAsync(timeout.Token);
using var cancelled = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => second.WaitAsync(cancelled.Token));
first.Release();
await second.WaitAsync(timeout.Token);
second.Release();
}
finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); }
}
[Fact]
public async Task NonContentionIoErrorsAreNotRetried()
{
var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock");
Directory.CreateDirectory(path); // A directory cannot be opened as the lock file.
try
{
using var gate = new InterprocessCommandGate(path);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var error = await Record.ExceptionAsync(() => gate.WaitAsync(timeout.Token));
Assert.True(error is IOException or UnauthorizedAccessException);
Directory.Delete(path);
await gate.WaitAsync(timeout.Token);
gate.Release();
}
finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); }
}
}
@@ -0,0 +1,48 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class KeyCachePlannerTests
{
private static AccountKeySet CachedAccount(string fingerprint, params string[] paths) => new(
fingerprint, "C:\\xwechat_files\\" + fingerprint, "4.1.13.63", DateTimeOffset.UtcNow,
paths.Select(path => new DatabaseKeyEvidence(path, "salt", "key", 42, DateTimeOffset.UtcNow, KeyBindingConfidence.PageHmacVerified)).ToArray());
private static AccountDatabaseEntry Entry(string fingerprint, string path) => new(fingerprint, path);
[Fact]
public void VerifiedKeysCoversEveryCachedDatabasePath()
{
var cached = new[] { CachedAccount("a", "contact.db", "message_0.db"), CachedAccount("b", "message_0.db") };
var verified = KeyCachePlanner.VerifiedKeys(cached);
Assert.Contains(KeyCachePlanner.Key("a", "contact.db"), verified);
Assert.Contains(KeyCachePlanner.Key("a", "message_0.db"), verified);
Assert.Contains(KeyCachePlanner.Key("b", "message_0.db"), verified);
}
[Fact]
public void HasPendingIsFalseWhenAllDiscoveredDatabasesAreCached()
{
var cached = new[] { CachedAccount("a", "contact.db", "message_0.db") };
var verified = KeyCachePlanner.VerifiedKeys(cached);
var discovered = new[] { Entry("a", "contact.db"), Entry("a", "message_0.db") };
Assert.False(KeyCachePlanner.HasPending(discovered, verified));
}
[Fact]
public void HasPendingIsTrueForNewDatabaseOrNewAccount()
{
var cached = new[] { CachedAccount("a", "contact.db") };
var verified = KeyCachePlanner.VerifiedKeys(cached);
Assert.True(KeyCachePlanner.HasPending([Entry("a", "message_0.db")], verified));
Assert.True(KeyCachePlanner.HasPending([Entry("b", "contact.db")], verified));
}
[Fact]
public void CacheKeyIsCaseInsensitiveForWindowsPaths()
{
var verified = KeyCachePlanner.VerifiedKeys([CachedAccount("a", "Contact.DB")]);
Assert.True(verified.Contains(KeyCachePlanner.Key("a", "contact.db")));
}
}
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using System.Text;
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class MemoryScanningTests
{
private static byte[] EncodedLiteral(string literal)
{
var bytes = Encoding.ASCII.GetBytes(literal);
var mask = Convert.FromHexString("d2c7442458020000004889442450488b450048844c2448488944254048584c24");
for (var i = 0; i < bytes.Length; i++) bytes[i] ^= mask[i % mask.Length];
return bytes;
}
[Fact]
public void ConfigCipherDecodeFindsKeyAndEmbeddedSalt()
{
var key = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f";
var salt = "101112131415161718191a1b1c1d1e1f";
var decoded = WcdbConfigCipher.Decode(EncodedLiteral($"x'{key}{salt}'"));
Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(decoded));
}
[Fact]
public void ConfigCipherDecodeWindowsOnLongRuns()
{
var key = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f";
var salt = "101112131415161718191a1b1c1d1e1f";
var run = key + salt + key + salt;
var decoded = WcdbConfigCipher.Decode(EncodedLiteral($"x'{run}'"));
Assert.Contains(decoded, item => item.EncKey == key && item.SaltHint == salt);
Assert.Equal(4, decoded.Count);
}
[Fact]
public void ConfigCipherDecodeRejectsBoundsAndUniformKeys()
{
Assert.Empty(WcdbConfigCipher.Decode([]));
Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("x'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'" + "00000000000000000000000000000000")));
Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("plain")));
Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("x''")));
var tooLong = EncodedLiteral($"x'{new string('e', 200)}'");
Assert.Empty(WcdbConfigCipher.Decode(tooLong));
}
[Fact]
public void ConfigCipherDecodeRejectsBlobsOverLimit()
{
var literal = $"x'{new string('f', 64)}{new string('1', 32)}'";
var blob = new List<byte>(EncodedLiteral(literal));
while (blob.Count <= WcdbConfigCipher.MaximumBlobLength) blob.Insert(0, 0x00);
Assert.Empty(WcdbConfigCipher.Decode(blob.ToArray()));
}
[Fact]
public void PatternScannerMatchesAcrossChunkBoundaries()
{
var needle = Encoding.ASCII.GetBytes("com.Tencent.WCDB.Config.Cipher");
var content = Encoding.ASCII.GetBytes("prefix " + Encoding.ASCII.GetString(needle) + " suffix");
var split = 10;
var scanner = new MemoryPatternScanner(needle);
var matches = scanner.Feed(content.AsSpan(0, split), 0x1000).ToList();
matches.AddRange(scanner.Feed(content.AsSpan(split), 0x1000 + (ulong)split));
Assert.Equal([0x1000 + 7UL], matches);
}
[Fact]
public void PatternScannerReportsMatchesInSecondChunk()
{
var needle = Encoding.ASCII.GetBytes("abc");
var content = Encoding.ASCII.GetBytes("xxabc");
var scanner = new MemoryPatternScanner(needle);
var matches = scanner.Feed(content.AsSpan(0, 3), 0x2000).ToList();
matches.AddRange(scanner.Feed(content.AsSpan(3), 0x2003));
Assert.Equal([0x2002UL], matches);
}
[Fact]
public void PatternScannerDoesNotJoinAcrossGaps()
{
var needle = Encoding.ASCII.GetBytes("abc");
var scanner = new MemoryPatternScanner(needle);
var matches = scanner.Feed("a"u8[..1], 0x3000).ToList();
matches.AddRange(scanner.Feed("b"u8[..1], 0x4000)); // Gap: end was 0x3001, next feed at 0x4000.
matches.AddRange(scanner.Feed("c"u8[..1], 0x4001));
Assert.Empty(matches);
}
[Fact]
public void PatternScannerFindsMatchSpanningThreeChunks()
{
var needle = Encoding.ASCII.GetBytes("abcde");
var content = Encoding.ASCII.GetBytes("xxabcde");
var scanner = new MemoryPatternScanner(needle);
var matches = scanner.Feed(content.AsSpan(0, 3), 0x5000).ToList();
matches.AddRange(scanner.Feed(content.AsSpan(3, 3), 0x5003));
matches.AddRange(scanner.Feed(content.AsSpan(6), 0x5006));
Assert.Equal([0x5002UL], matches);
}
[Fact]
public void DecodeProducesNoMatchAfterPatternError()
{
// Simulates the reference's own fail-open behavior after a failed read.
var decoded = WcdbConfigCipher.Decode(Encoding.ASCII.GetBytes("x'abc'"));
Assert.Empty(decoded);
}
}
@@ -0,0 +1,47 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class UiDiagnosticsTests
{
[Fact]
public void TraversalGuard_StopsCyclesBeforeRecursing()
{
var guard = new UiTraversalGuard();
Assert.True(guard.TryVisit("root", 0));
Assert.True(guard.TryVisit("child", 1));
Assert.False(guard.TryVisit("root", 2));
Assert.True(guard.CycleDetected);
Assert.Equal(2, guard.NodeCount);
}
[Fact]
public void TraversalGuard_BoundsDepthAndNodesEvenWithMissingRuntimeIds()
{
var depth = new UiTraversalGuard(maxDepth: 2);
Assert.True(depth.TryVisit("", 0));
Assert.False(depth.TryVisit("", 3));
Assert.True(depth.Truncated);
var count = new UiTraversalGuard(maxNodes: 2);
Assert.True(count.TryVisit("", 0));
Assert.True(count.TryVisit("", 1));
Assert.False(count.TryVisit("", 2));
Assert.Equal(2, count.NodeCount);
}
[Fact]
public void SnapshotComparison_DetectsRequiredControlRegressionWithoutLeakingDynamicIds()
{
var baseline = Root(WechatLocators.RequiredAutomationIds);
var missing = WechatLocators.MessageList;
var current = Root(WechatLocators.RequiredAutomationIds.Where(id => id != missing).Append("[id:private-hash]").Append("new_static_control"));
var report = UiSnapshotComparison.Compare(baseline, current);
Assert.Equal([missing], report.MissingRequiredControls);
Assert.Equal([missing], report.RemovedAutomationIds);
Assert.Equal(["new_static_control"], report.AddedAutomationIds);
}
private static UiNodeSnapshot Root(IEnumerable<string> ids) =>
new("Window", "", "", true, "0,0,800,600", ids.Select(id => new UiNodeSnapshot("Pane", "", id, true, "", [])).ToArray());
}
@@ -0,0 +1,46 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatContactQueryTests
{
[Fact]
public void ParametersAreBoundAndFetchOneExtraRow()
{
const string filter = "O'Brien_%'; DROP TABLE contact;--";
var parameters = WechatContactQuery.Parameters(2, 4, filter, true).ToDictionary(pair => pair.Key, pair => pair.Value);
Assert.Equal(3, parameters["$limit"]);
Assert.Equal(4, parameters["$offset"]);
Assert.Equal(1, parameters["$groups"]);
Assert.Equal(filter, parameters["$contains"]);
Assert.DoesNotContain(filter, WechatContactQuery.Sql);
Assert.Contains("ORDER BY username COLLATE BINARY", WechatContactQuery.Sql);
Assert.Equal(0, WechatContactQuery.Parameters(2, 0, null, false).Last().Value);
Assert.Null(WechatContactQuery.Parameters(2, 0, null, null).Last().Value);
}
[Theory]
[InlineData(0, 0)]
[InlineData(10001, 0)]
[InlineData(1, -1)]
[InlineData(2, int.MaxValue)]
public void InvalidBoundsAreRejected(int limit, int offset) =>
Assert.Equal(WxAgentErrorCode.InvalidArgument,
Assert.Throws<WxAgentException>(() => WechatContactQuery.Parameters(limit, offset, null, null)).Code);
[Fact]
public void PageSignalsTruncationWithoutCollapsingDuplicateDisplayNames()
{
WechatDatabaseContact[] contacts = [new("a", "same", null, null), new("b", "same", null, null), new("c", null, "remark", null)];
var page = WechatContactQuery.Page(contacts, 2, 4);
Assert.True(page.HasMore);
Assert.Equal(6, page.NextOffset);
Assert.Equal(new[] { "a", "b" }, page.Contacts.Select(contact => contact.Username));
var last = WechatContactQuery.Page(contacts[2..], 2, 6);
Assert.False(last.HasMore);
Assert.Equal("remark", last.Contacts[0].DisplayName);
Assert.False(WechatContactQuery.Page([], 2, 0).HasMore);
Assert.Equal("wxid", new WechatDatabaseContact("wxid", "", " ", null).DisplayName);
}
}
@@ -0,0 +1,66 @@
using System.Text;
using WxAgent.Core;
using Xunit;
using ZstdSharp;
namespace WxAgent.Core.Tests;
public sealed class WechatDbMessageTests
{
[Fact]
public void TableNameForMatchesMd5HexOfChatId()
{
Assert.Equal("Msg_" + Md5Hex("wxid_test"), WechatDbMessage.TableNameFor("wxid_test"));
Assert.Equal("Msg_" + Md5Hex("测试群组"), WechatDbMessage.TableNameFor("测试群组"));
}
[Fact]
public void DecodeContentHandlesPlainHexUtf8()
{
Assert.Equal("hello", WechatDbMessage.DecodeContent(Convert.ToHexString(Encoding.UTF8.GetBytes("hello")), compressed: false));
Assert.Empty(WechatDbMessage.DecodeContent("", compressed: false));
Assert.Empty(WechatDbMessage.DecodeContent("not-hex!", compressed: false));
}
[Fact]
public void DecodeContentDecompressesZstdPayloads()
{
var payload = Encoding.UTF8.GetBytes("wx-agent-zstd-roundtrip-消息测试");
using var compressor = new CompressionStream(new MemoryStream());
// ZstdSharp.CompressionStream writes on dispose; wrap in a memory stream instead.
var compressed = Compress(payload);
var hex = Convert.ToHexString(compressed);
var decoded = WechatDbMessage.DecodeContent(hex, compressed: true);
Assert.Equal("wx-agent-zstd-roundtrip-消息测试", decoded);
}
[Fact]
public void DecodeContentTreatsCompressedFlagWithoutMagicAsUndecodable()
{
var hex = Convert.ToHexString(Encoding.UTF8.GetBytes("plain"));
Assert.Empty(WechatDbMessage.DecodeContent(hex, compressed: true));
}
[Fact]
public void IsSelfUsesWxIdPrefixOfAccountDirectory()
{
Assert.True(WechatDbMessage.IsSelf("wxid_abc123", "wxid_abc123_def456"));
Assert.False(WechatDbMessage.IsSelf("wxid_other", "wxid_abc123_def456"));
Assert.Null(WechatDbMessage.IsSelf(null, "wxid_abc123_def456"));
Assert.Null(WechatDbMessage.IsSelf("wxid_abc123", null));
}
private static string Md5Hex(string value) =>
Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static byte[] Compress(byte[] payload)
{
using var output = new MemoryStream();
using (var compressor = new CompressionStream(output))
{
compressor.Write(payload);
}
return output.ToArray();
}
}
@@ -0,0 +1,29 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatDerivedTextTests
{
[Fact]
public void OnlyAddedTextIsReturnedAndDuplicateLinesArePreserved()
{
Assert.Equal($"result{Environment.NewLine}result",
WechatDerivedText.AddedText(["original", "timestamp"], ["original", "timestamp", "result", "result"]));
Assert.Equal("same", WechatDerivedText.AddedText(["same"], ["same", "same"]));
Assert.Null(WechatDerivedText.AddedText(["original"], ["original"]));
Assert.Null(WechatDerivedText.AddedText(["original"], ["unrelated replacement"]));
Assert.Null(WechatDerivedText.AddedText(["same", "same"], ["same", "new"]));
}
[Theory]
[InlineData("识别中")]
[InlineData("转换中…")]
[InlineData("语音转换失败")]
[InlineData("识别失败,请重试")]
[InlineData("未识别到文字")]
[InlineData("无法转换")]
[InlineData(" ")]
public void TransientOrFailedResultsAreNotReturned(string status) =>
Assert.Null(WechatDerivedText.AddedText(["original"], ["original", status]));
}
@@ -0,0 +1,41 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatGroupMemberQueryTests
{
[Fact]
public void QueryUsesStableIdsAndBoundParameters()
{
var parameters = WechatGroupMemberQuery.Parameters("123@chatroom", 2, 4).ToDictionary(pair => pair.Key, pair => pair.Value);
Assert.Equal("123@chatroom", parameters["$group"]);
Assert.Equal(3, parameters["$limit"]);
Assert.Equal(4, parameters["$offset"]);
Assert.Contains("c.id = m.member_id", WechatGroupMemberQuery.Sql);
Assert.Contains("ORDER BY m.member_id", WechatGroupMemberQuery.Sql);
Assert.DoesNotContain("123@chatroom", WechatGroupMemberQuery.Sql);
}
[Theory]
[InlineData("group")]
[InlineData("")]
[InlineData(" ")]
public void StableGroupUsernameIsRequired(string group) =>
Assert.Equal(WxAgentErrorCode.InvalidArgument,
Assert.Throws<WxAgentException>(() => WechatGroupMemberQuery.Parameters(group, 1, 0)).Code);
[Fact]
public void PagePreservesDuplicateDisplayNamesAndSignalsNextOffset()
{
WechatDatabaseGroupMember[] rows =
[
new(1, "one", "same", true), new(2, "two", "same", false), new(3, "three", "three", false)
];
var page = WechatGroupMemberQuery.Page("123@chatroom", rows, 2, 4);
Assert.True(page.HasMore);
Assert.Equal(6, page.NextOffset);
Assert.Equal(new[] { "one", "two" }, page.Members.Select(member => member.Username));
Assert.True(page.Members[0].IsOwner);
}
}
@@ -0,0 +1,76 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatM5Tests
{
[Fact]
public void GetUrl_ExtractsFirstHttpUrlAndTrailingPunctuation()
{
var message = Message("文档:https://example.com/a?q=1。", ChatMessageType.Link);
Assert.Equal("https://example.com/a?q=1", WechatMessageContentParser.GetUrl(message)?.AbsoluteUri);
}
[Fact]
public void ToMarkdown_FormatsLinkAndQuote()
{
var message = Message(
"项目主页 https://example.com",
ChatMessageType.Link,
new QuotedMessageSnapshot("甲", "原消息"));
Assert.Equal("> 原消息\n\n[项目主页](https://example.com/)", WechatMessageContentParser.ToMarkdown(message).Replace("\r\n", "\n"));
}
[Fact]
public void GetContent_PreservesTypedMessageData()
{
var message = Message("[图片]", ChatMessageType.Image);
var content = WechatMessageContentParser.GetContent(message);
Assert.Equal(ChatMessageType.Image, content.Type);
Assert.Equal("[图片]", content.Text);
Assert.Null(content.Url);
}
[Fact]
public void MomentFingerprint_IsStableAndSensitiveToContent()
{
var first = WechatMessageContentParser.MomentFingerprint("甲", "内容", "昨天");
Assert.Equal(first, WechatMessageContentParser.MomentFingerprint("甲", "内容", "昨天"));
Assert.NotEqual(first, WechatMessageContentParser.MomentFingerprint("甲", "其他", "昨天"));
}
[Theory]
[InlineData("[位置]", ChatMessageType.Location)]
[InlineData("[动画表情]", ChatMessageType.Emotion)]
[InlineData("三条聊天记录", ChatMessageType.Merge)]
[InlineData("[名片]", ChatMessageType.PersonalCard)]
[InlineData("[笔记]", ChatMessageType.Note)]
public void Parser_ClassifiesM5SpecialMessages(string text, ChatMessageType expected)
{
var parsed = VisibleMessageParser.Parse([new ChatMessageSource(text, "id")]);
Assert.Equal(expected, Assert.Single(parsed).Type);
}
[Fact]
public void NoteContent_ToMarkdown_PreservesOrderAndFiles()
{
var content = new WechatNoteContent([
new WechatNoteContentPart("text", "标题"),
new WechatNoteContentPart("file", "/tmp/a.png")
]);
Assert.Equal("标题\n\n[a.png](/tmp/a.png)", content.ToMarkdown().Replace("\r\n", "\n"));
}
[Fact]
public void MomentPrivacy_RequiresTagsForRestrictedVisibility()
{
new WechatMomentPublishOptions().Validate();
Assert.Throws<WxAgentException>(() => new WechatMomentPublishOptions(WechatMomentPrivacy.TagWhitelist).Validate());
new WechatMomentPublishOptions(WechatMomentPrivacy.TagBlacklist, ["测试标签"]).Validate();
}
private static ChatMessageSnapshot Message(string text, ChatMessageType type, QuotedMessageSnapshot? quote = null) =>
new(text, "fingerprint", 0, type, quote);
}
@@ -0,0 +1,60 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatManagementTests
{
[Fact]
public void RequireConfirmation_RejectsMissingOrWrongToken()
{
var missing = Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireConfirmation(null, "delete session"));
var wrong = Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireConfirmation("confirm", "delete session"));
Assert.Equal(WxAgentErrorCode.InvalidArgument, missing.Code);
Assert.Equal(WxAgentErrorCode.InvalidArgument, wrong.Code);
}
[Fact]
public void RequireConfirmation_AcceptsExactToken()
{
WechatOperationPolicy.RequireConfirmation(WechatOperationPolicy.Confirmation, "delete session");
}
[Fact]
public void ValidateNames_RequiresAtLeastOneUsableName()
{
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames([], "members"));
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames(["ok", ""], "members"));
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames(["same", "same"], "members"));
WechatOperationPolicy.ValidateNames(["one", "two"], "members");
}
[Fact]
public void RequireUnique_RejectsMissingAndAmbiguousTargets()
{
Assert.Equal("one", WechatOperationPolicy.RequireUnique(new[] { "one" }, "target"));
Assert.Equal(WxAgentErrorCode.ControlNotFound,
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireUnique(Array.Empty<string>(), "target")).Code);
Assert.Equal(WxAgentErrorCode.ControlNotFound,
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireUnique(new[] { "one", "two" }, "target")).Code);
}
[Fact]
public void OperationResult_UnconfirmedIsNotSuccessAndWarnsAgainstRetry()
{
var result = WechatOperationResult.Unconfirmed("Submission");
Assert.False(result.Success);
Assert.Equal(WxAgentErrorCode.ResultUnconfirmed, result.Code);
Assert.Contains("Do not retry", result.Message);
}
[Fact]
public void OperationResult_OkHasNoErrorCode()
{
var result = WechatOperationResult.Ok("done");
Assert.True(result.Success);
Assert.Null(result.Code);
Assert.Equal("done", result.Message);
}
}
@@ -0,0 +1,31 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatMentionConfirmationTests
{
[Theory]
[InlineData("\uFFFC", true)]
[InlineData("\uFFFC\u2005", true)]
[InlineData("@Hao 豪", false)]
[InlineData("@", false)]
[InlineData("\uFFFCunexpected", false)]
[InlineData("\uFFFC\uFFFC", false)]
public void RequiresExactlyOneStructuredMentionToken(string value, bool expected) =>
Assert.Equal(expected, WechatSendConfirmation.IsMentionToken(value));
[Theory]
[InlineData("@Hao 豪 marker", "Hao 豪", true)]
[InlineData("@Hao 豪\u2005marker", "Hao 豪", true)]
[InlineData("@所有人 marker", "所有人", true)]
[InlineData("@Hao 豪 other", "Hao 豪", false)]
[InlineData("@Other marker", "Hao 豪", false)]
public void RequiresFreshExactMentionAndBody(string text, string member, bool expected)
{
var message = new ChatMessageSnapshot(text, "fresh", 0, ChatMessageType.Text);
Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, new HashSet<string>(), member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet<string> { "fresh" }, member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet<string>(), member, "marker"));
}
}
@@ -0,0 +1,84 @@
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using WxAgent.Core;
using Xunit;
using ZstdSharp;
namespace WxAgent.Core.Tests;
public sealed class WechatMergedChatTests
{
[Fact]
public void ParsesEmbeddedTextMetadataWithoutDeduplicatingRepeatedMessages()
{
var result = WechatMergedChatParser.Parse(Wrap(Record(Item("same\n\ntext"), Item("same\n\ntext"))));
Assert.Equal(2, result.Messages.Count);
Assert.Equal("0", result.Messages[0].Path);
Assert.Equal("1", result.Messages[1].Path);
Assert.Equal("same\n\ntext", result.Messages[0].Text);
Assert.Equal("Synthetic sender", result.Messages[0].SenderName);
Assert.Equal("opaque-hash", result.Messages[0].SenderHash);
Assert.Equal("18446744073709551615", result.Messages[0].SourceServerId);
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1700000000), result.Messages[0].Timestamp);
}
[Fact]
public void SupportsNestedRecordsAndExplicitAttachmentMetadata()
{
var attachment = new XElement("dataitem", new XAttribute("datatype", 8),
new XElement("datatitle", "example.txt"), new XElement("datafmt", "txt"), new XElement("datasize", 42),
new XElement("cdn_dataurl", "must-not-be-exported"), new XElement("aeskey", "must-not-be-exported"));
var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Item("nested")));
var result = WechatMergedChatParser.Parse(Record(attachment, nested).ToString());
Assert.Null(result.Messages[0].Text);
Assert.Equal(42, result.Messages[0].SizeBytes);
Assert.Equal("1/0", result.Messages[1].NestedRecord!.Messages[0].Path);
Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result));
}
[Fact]
public void RejectsTruncationMissingPayloadAndInvalidTimestamp()
{
var record = Record(Item("hello"));
record.Element("datalist")!.SetAttributeValue("count", 2);
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(record.ToString()));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(new XElement("dataitem", new XAttribute("datatype", 1))).ToString()));
var item = Item("hello");
item.Element("srcMsgCreateTime")!.Value = long.MaxValue.ToString();
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(item).ToString()));
}
[Fact]
public void RejectsDtdAndBoundViolations()
{
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse("<!DOCTYPE recordinfo [<!ENTITY x SYSTEM 'file:///not-read'>]><recordinfo>&x;</recordinfo>"));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1)));
var nested = Record(Item("innermost"));
for (var i = 0; i < WechatMergedChatParser.MaxNesting; i++)
nested = Record(new XElement("dataitem", new XAttribute("datatype", 17), nested));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(nested.ToString()));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(Enumerable.Range(0, WechatMergedChatParser.MaxMessages + 1).Select(_ => Item("x")).ToArray()).ToString()));
}
[Fact]
public void PreservesComposite64BitMessageTypeAndBoundsZstdDecoding()
{
var message = new DbMessage(149, 123, "filehelper", null, null, null, 81604378673L, "xml", DateTimeOffset.UnixEpoch, null);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(message));
Assert.Equal(81604378673L, json.RootElement.GetProperty("Type").GetInt64());
using var output = new MemoryStream();
using (var compressed = new CompressionStream(output))
compressed.Write(Encoding.UTF8.GetBytes(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1)));
Assert.Throws<InvalidDataException>(() => WechatDbMessage.DecodeContent(Convert.ToHexString(output.ToArray()), true));
}
private static XElement Item(string text) => new("dataitem", new XAttribute("datatype", 1), new XAttribute("dataid", "repeated-id"),
new XElement("datadesc", text), new XElement("sourcename", "Synthetic sender"), new XElement("srcMsgLocalId", "7"),
new XElement("srcMsgCreateTime", "1700000000"), new XElement("fromnewmsgid", "18446744073709551615"),
new XElement("dataitemsource", new XElement("hashusername", "opaque-hash")));
private static XElement Record(params XElement[] items) => new("recordinfo", new XElement("title", "Synthetic record"),
new XElement("datalist", new XAttribute("count", items.Length), items));
private static string Wrap(XElement record) => new XElement("msg", new XElement("appmsg", new XElement("type", 19),
new XElement("recorditem", new XCData(record.ToString(SaveOptions.DisableFormatting))))).ToString();
}
@@ -0,0 +1,23 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatNavigationTests
{
[Theory]
[InlineData(0)]
[InlineData(101)]
public void RejectsUnboundedScrollRequests(int pages) =>
Assert.Throws<WxAgentException>(() => WechatNavigationRules.ValidateScrollLimit(pages));
[Fact]
public void MatchesExactVisibleFingerprintAndRejectsAmbiguity()
{
var message = new ChatMessageSnapshot("test", new string('a', 64), 0, ChatMessageType.Text);
Assert.Same(message, WechatNavigationRules.FindMessage([message], new string('A', 64)));
Assert.Null(WechatNavigationRules.FindMessage([message], new string('b', 64)));
Assert.Throws<WxAgentException>(() => WechatNavigationRules.FindMessage([message, message], message.Fingerprint));
Assert.Throws<WxAgentException>(() => WechatNavigationRules.FindMessage([message], "not-a-fingerprint"));
}
}
@@ -0,0 +1,37 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatNoteWindowTests
{
[Fact]
public void OnlyAFreshVisibleNoteInTheExpectedProcessIsSelected()
{
WechatNoteWindowCandidate[] windows = [
new(1, 10, "笔记", true, false), // Existing note must not be exported instead of the selected message.
new(2, 10, "笔记交流群", true, true),
new(3, 20, "笔记", true, false),
new(4, 10, "笔记", false, false),
new(5, 10, "微信", true, false),
new(6, 10, "笔记", true, false)];
Assert.Equal(6, WechatNoteWindow.FindOpened(windows, 10, new HashSet<long> { 1 }));
Assert.Null(WechatNoteWindow.FindOpened(windows[..5], 10, new HashSet<long> { 1 }));
}
[Fact]
public void AmbiguousNewNotesAreRejected()
{
var error = Assert.Throws<WxAgentException>(() => WechatNoteWindow.FindOpened([
new(1, 10, "笔记", true, false), new(2, 10, "笔记", true, false)], 10, new HashSet<long>()));
Assert.Equal(WxAgentErrorCode.UiStructureChanged, error.Code);
}
[Fact]
public void RepeatedNoteParagraphsMustRemainRepeated()
{
var content = new WechatNoteContent([
new("text", "重复段落"), new("text", "重复段落")]);
Assert.Equal("重复段落\n\n重复段落", content.ToMarkdown().ReplaceLineEndings("\n"));
}
}
@@ -0,0 +1,80 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatSearchRulesTests
{
[Fact]
public void CollapsedGroupsExposeALocalExpansion_NotANetworkKeyword()
{
var page = WechatSearchRules.Parse([
("", "群聊"), ("search_item_技术交流群", "群聊\n技术交流群"), ("", "查看全部(10)"),
("", "网络搜索"), ("search_item_web_交流", "网络搜索\n交流"), ("", "交流")], "交流");
Assert.Single(page.Results);
Assert.Equal("技术交流群", page.Results[0].Name);
Assert.False(page.Results[0].IsExactMatch);
Assert.Equal("技术交流群", WechatSearchRules.RequireExact(page.Results, "技术交流群").Name);
Assert.Equal(new WechatSearchExpansion("群聊", 2, 10), Assert.Single(page.Expansions));
Assert.Throws<WxAgentException>(() => WechatSearchRules.RequireExact(page.Results, "交流"));
}
[Fact]
public void GroupsAndContactsAreBothLocal_AndDuplicateNamesRemainAmbiguous()
{
var page = WechatSearchRules.Parse([
("", "联系人"), ("search_item_交流", "联系人\n交流"),
("", "群聊"), ("search_item_交流", "群聊\n交流")], "交流");
Assert.Equal(2, page.Results.Count);
Assert.Equal(WxAgentErrorCode.InvalidOperationState,
Assert.Throws<WxAgentException>(() => WechatSearchRules.RequireExact(page.Results, "交流")).Code);
}
[Fact]
public void ExpandedLocalPageStillExcludesNetworkAndHistory()
{
var page = WechatSearchRules.Parse([
("search_item_技术交流群", "技术交流群"), ("search_item_web_交流", "交流"),
("", "聊天记录"), ("search_item_历史交流", "交流")], "交流", "群聊");
Assert.Equal("技术交流群", Assert.Single(page.Results).Name);
Assert.Empty(page.Expansions);
}
[Fact]
public void ExpandedPageDoesNotTrustUnknownSectionsOrTheirSearchIds()
{
var page = WechatSearchRules.Parse([
("search_item_技术交流群", "技术交流群"),
("", "未知类别"), ("search_item_new_kind_交流", "交流")], "交流", "群聊");
Assert.Equal("技术交流群", Assert.Single(page.Results).Name);
}
[Fact]
public void PromotedLocalConversationRemainsSelectableButUnknownFamiliesDoNot()
{
var page = WechatSearchRules.Parse([
("", "最常使用"), ("search_item_技术交流群", "技术交流群"),
("search_item_function_交流", "交流"), ("search_item_online_交流", "交流"),
("search_item_future_交流", "交流")], "交流");
Assert.Equal("技术交流群", WechatSearchRules.RequireExact(page.Results, "技术交流群").Name);
Assert.Single(page.Results);
}
[Fact]
public void ACategoryCaptionIsNotTheConversationName()
{
var page = WechatSearchRules.Parse([("", "群聊"), ("search_item_技术群", "群聊\n技术群")], "群聊");
Assert.Equal("技术群", Assert.Single(page.Results).Name);
Assert.False(page.Results[0].IsExactMatch);
}
[Fact]
public void WebExpansionAndUnclassifiedSearchItemsAreNeverTargets()
{
var page = WechatSearchRules.Parse([
("search_item_交流", "交流"), ("", "网络搜索"), ("", "查看全部(99)")], "交流");
Assert.Empty(page.Results);
Assert.Empty(page.Expansions);
Assert.Throws<WxAgentException>(() => WechatSearchRules.Parse([], "交流", "网络搜索"));
}
}
@@ -0,0 +1,39 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatSendConfirmationTests
{
[Fact]
public void RequiresFreshMatchingTypeAndExactReplyText()
{
var baseline = new HashSet<string> { "old" };
var reply = new ChatMessageSnapshot("reply", "new", 0, ChatMessageType.Quote);
Assert.True(WechatSendConfirmation.Matches(reply, baseline, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, ChatMessageType.Quote, "reply"));
}
[Theory]
[InlineData("report.txt", true)]
[InlineData("文件\nreport.txt\n1 KB", true)]
[InlineData("文件\r\nreport.txt\r\n1 KB", true)]
[InlineData("other-report.txt", false)]
[InlineData("report.txt.backup", false)]
public void FileConfirmationRequiresExactFilenameLine(string text, bool expected)
{
var message = new ChatMessageSnapshot(text, "new", 0, ChatMessageType.File);
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.File, "report.txt"));
}
[Fact]
public void ImageConfirmationRejectsOtherTypesAndOldRows()
{
var message = new ChatMessageSnapshot("[图片]", "new", 0, ChatMessageType.Image);
Assert.True(WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message, new HashSet<string> { "new" }, ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet<string>(), ChatMessageType.Image, null));
}
}
@@ -0,0 +1,189 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatStabilityTests
{
[Fact]
public void Deduper_HandlesTenThousandMessagesAndEvictsOldEntries()
{
var deduper = new BoundedMessageDeduper(2048);
for (var index = 0; index < 10_000; index++)
Assert.True(deduper.TryAdd($"message-{index}"));
Assert.False(deduper.TryAdd("message-9999"));
Assert.True(deduper.TryAdd("message-0"));
}
[Theory]
[InlineData("Qt51514QWindowIcon", "微信", true)]
[InlineData("Qt6QWindowIcon", "微信", true)]
[InlineData("Qt51514QWindowIcon", "文件传输助手", false)]
[InlineData("Qt51514WxTrayIconMessageWindowClass", "微信", false)]
[InlineData("Notepad", "微信", false)]
public void NativeWindowMatching_RejectsSubwindowsAndOtherApplications(string className, string title, bool expected)
{
Assert.Equal(expected, WechatLocators.IsNativeMainWindow(className, title));
}
[Theory]
[InlineData("微信", true)]
[InlineData("微信\n收到新消息", true)]
[InlineData("WeChat", true)]
[InlineData("企业微信", false)]
[InlineData("微信测试脚本", false)]
public void TrayMatching_IsExplicit(string name, bool expected) =>
Assert.Equal(expected, WechatLocators.IsWechatTrayName(name));
[Fact]
public void StabilityAnalyzer_DetectsAgentLeakAndFinalError()
{
var now = DateTimeOffset.UtcNow;
var first = Sample(now, 100, 10, 1);
var last = first with { Timestamp = now.AddHours(1), AgentHandleCount = 101, Errors = [WxAgentErrorCode.SessionLocked] };
var report = WechatStabilityAnalyzer.Analyze(now, last.Timestamp, [first, last], 0, 0);
Assert.False(report.Healthy);
Assert.Contains("agent-handle-growth-over-100", report.Findings);
Assert.Contains("final-error:SessionLocked", report.Findings);
}
[Fact]
public void Deduper_RejectsInvalidCapacity()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new BoundedMessageDeduper(0));
}
[Fact]
public void StabilityAnalyzer_ReportsHealthyBoundedGrowth()
{
var started = DateTimeOffset.UtcNow;
var samples = new[]
{
Sample(started, 100_000_000, 100, 20),
Sample(started.AddMinutes(1), 110_000_000, 120, 22)
};
var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(1), samples, 5, 1);
Assert.True(report.Healthy);
Assert.Equal(10_000_000, report.WorkingSetGrowthBytes);
Assert.Empty(report.Findings);
}
[Fact]
public void StabilityAnalyzer_AllowsTransientWindowLossAfterReconnect()
{
var started = DateTimeOffset.UtcNow;
var samples = new[]
{
Sample(started, 100_000_000, 100, 20),
Sample(started.AddMinutes(1), 0, 0, 0, windowFound: false),
Sample(started.AddMinutes(2), 105_000_000, 110, 21)
};
var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(2), samples, 0, 1);
Assert.True(report.Healthy);
Assert.DoesNotContain("wechat-window-unavailable", report.Findings);
}
[Fact]
public void StabilityAnalyzer_FlagsWindowLossAndResourceGrowth()
{
var started = DateTimeOffset.UtcNow;
var samples = new[]
{
Sample(started, 100_000_000, 100, 20),
Sample(started.AddHours(1), 400_000_000, 700, 150, windowFound: false)
};
var report = WechatStabilityAnalyzer.Analyze(started, started.AddHours(1), samples, 0, 0);
Assert.False(report.Healthy);
Assert.Contains("wechat-window-unavailable", report.Findings);
Assert.Contains("working-set-growth-over-256mb", report.Findings);
Assert.Contains("handle-growth-over-500", report.Findings);
Assert.Contains("thread-growth-over-100", report.Findings);
}
[Theory]
[InlineData(30, false, 0, true)]
[InlineData(15, false, 0, false)]
[InlineData(30, true, 0, false)]
[InlineData(30, false, 11, false)]
public void RunChecks_ApplyDurationCancellationAndHeartbeatToEveryReport(int seconds, bool cancelled, int snapshotAge, bool healthy)
{
var finish = DateTimeOffset.UtcNow;
var last = Sample(finish, 100, 10, 1) with { LastSnapshotAt = finish.AddSeconds(-snapshotAge) };
var report = WechatStabilityAnalyzer.Analyze(finish.AddSeconds(-seconds), finish, [last], 0, 0);
var checkedReport = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(seconds), cancelled);
Assert.Equal(healthy, checkedReport.Healthy);
Assert.Equal(seconds >= 29 && !cancelled, checkedReport.CompletedDuration);
}
[Fact]
public void RunChecks_RejectMissingHeartbeatAndBackgroundFailures()
{
var finish = DateTimeOffset.UtcNow;
var report = WechatStabilityAnalyzer.Analyze(finish.AddHours(-8), finish, [Sample(finish, 0, 0, 0)], 0, 0);
report = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromHours(8), TimeSpan.FromHours(8), false,
"InvalidOperationException", "IOException");
Assert.False(report.Healthy);
Assert.Contains("listener-snapshot-stale", report.Findings);
Assert.Contains("listener-failed:InvalidOperationException", report.Findings);
Assert.Contains("sampling-failed:IOException", report.Findings);
}
[Fact]
public void StabilityAnalyzer_DoesNotHideMissingHeartbeatBeforeRecovery()
{
var started = DateTimeOffset.UtcNow;
var missing = Sample(started.AddMinutes(6), 100, 10, 1);
var recovered = missing with { Timestamp = started.AddMinutes(7), LastSnapshotAt = started.AddMinutes(7) };
var report = WechatStabilityAnalyzer.Analyze(started, recovered.Timestamp, [missing, recovered], 0, 1);
Assert.Contains("listener-snapshot-gap-over-5min", report.Findings);
Assert.False(report.Healthy);
}
[Fact]
public async Task ReportWrite_PreservesExistingEvidenceWhenCancelled()
{
var directory = Path.Combine(Path.GetTempPath(), "wx-stability-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
try
{
var path = Path.Combine(directory, "report.json");
await File.WriteAllTextAsync(path, "previous evidence");
var now = DateTimeOffset.UtcNow;
var report = WechatStabilityAnalyzer.Analyze(now, now, [Sample(now, 0, 0, 0)], 0, 0);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
WechatStabilityAnalyzer.WriteJsonAsync(report, path, cancelled.Token));
Assert.Equal("previous evidence", await File.ReadAllTextAsync(path));
Assert.False(File.Exists(path + ".tmp"));
}
finally { Directory.Delete(directory, recursive: true); }
}
[Theory]
[InlineData("Shell_TrayWnd", true)]
[InlineData("Shell_SecondaryTrayWnd", true)]
[InlineData("NotifyIconOverflowWindow", true)]
[InlineData("Qt51514QWindowIcon", false)]
[InlineData("Chrome_WidgetWin_1", false)]
[InlineData("", false)]
public void TrayClicks_RequireANotificationAreaHitTarget(string className, bool expected)
{
Assert.Equal(expected, WechatLocators.IsNotificationAreaRoot(className));
}
private static WechatStabilitySample Sample(
DateTimeOffset timestamp,
long memory,
int handles,
int threads,
bool windowFound = true) =>
new(timestamp, 1, memory, handles, threads, windowFound, [], 0, 0);
}
@@ -0,0 +1,34 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatTextInputTests
{
[Fact]
public void PreservesMultilineWhitespaceWithCanonicalLineEndings()
{
Assert.Equal("first\n\n second\t\nthird", WechatTextInput.Prepare("first\r\n\r\n second\t\rthird"));
Assert.Equal(new string('x', 4000), WechatTextInput.Prepare(new string('x', 4000)));
Assert.Throws<WxAgentException>(() => WechatTextInput.Prepare(new string('x', 4001)));
}
[Theory]
[InlineData("")]
[InlineData(" \r\n\t")]
[InlineData("before\0after")]
[InlineData("before\u001bafter")]
public void RejectsEmptyOrUnsafeInput(string input) =>
Assert.Throws<WxAgentException>(() => WechatTextInput.Prepare(input));
[Fact]
public void QuoteParserPreservesBlankLinesAndIndentation()
{
const string reply = "first\n\n second ";
var parsed = Assert.Single(VisibleMessageParser.Parse([reply + "\n引用 Alice 的消息 : original"]));
Assert.Equal(reply, parsed.Text);
Assert.Equal(ChatMessageType.Quote, parsed.Type);
Assert.True(WechatSendConfirmation.Matches(parsed, new HashSet<string>(), ChatMessageType.Quote, reply));
Assert.Equal("original", parsed.Quote!.Text);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\node-agent\WxAgent.Core\WxAgent.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class AccountBindingStoreTests
{
[Fact]
public void ReplacesByAccountAndRemovesExplicitBinding()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
try
{
var store = new AccountBindingStore(new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "credentials.json") });
store.Replace([
new AccountBinding("a", 10, 20, "wxid-a", "Alice", DateTimeOffset.UtcNow),
new AccountBinding("a", 11, 21, "wxid-a", "Alice", DateTimeOffset.UtcNow),
new AccountBinding("b", 12, 22, "wxid-b", "Bob", DateTimeOffset.UtcNow)
]);
Assert.Equal(11, store.Get("a")!.ProcessId);
Assert.Equal(2, store.ReadAll().Count);
store.Remove("a");
Assert.Null(store.Get("a"));
Assert.Single(store.ReadAll());
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class ArtifactStoreTests
{
[Fact]
public async Task ArtifactIdsAreOpaqueOwnedBoundedAndPathSafe()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = "unused" };
try
{
var store = new ArtifactStore(options);
await using var body = new MemoryStream("safe"u8.ToArray());
var file = new FormFile(body, 0, body.Length, "file", "note.txt") { Headers = new HeaderDictionary(), ContentType = "text/plain" };
var info = await store.SaveAsync("alice", file, default);
Assert.Matches("^[0-9a-f]{32}$", info.ArtifactId);
Assert.Equal("alice", store.Get("alice", info.ArtifactId).PrincipalId);
Assert.Throws<ServiceException>(() => store.Get("bob", info.ArtifactId));
await using var opened = store.Open("alice", info.ArtifactId); using var reader = new StreamReader(opened);
Assert.Equal("safe", await reader.ReadToEndAsync());
Assert.Throws<ServiceException>(() => store.Get("alice", "../secrets"));
await using var badBody = new MemoryStream("x"u8.ToArray());
var bad = new FormFile(badBody, 0, badBody.Length, "file", "payload.exe") { Headers = new HeaderDictionary(), ContentType = "application/octet-stream" };
Assert.Equal("FileTypeRejected", (await Assert.ThrowsAsync<ServiceException>(() => store.SaveAsync("alice", bad, default))).Code);
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,35 @@
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class EventHubTests
{
[Fact]
public async Task ReplayAndGapAreExplicitAndSubscriptionsAreOwnerScoped()
{
var hub = new EventHub();
var first = new AgentEvent("one", "account", "chat", "message", "summary", DateTimeOffset.UtcNow);
hub.Publish("alice", first);
var replay = hub.Subscribe("alice", "one");
Assert.Empty(replay.Replay); Assert.False(replay.Gap);
var gap = hub.Subscribe("alice", "gone");
Assert.True(gap.Gap);
Assert.False(hub.TryGet(replay.SubscriptionId, "bob", out _));
hub.Publish("alice", new AgentEvent("two", "account", "chat", "message", "summary", DateTimeOffset.UtcNow));
Assert.True(hub.TryGet(replay.SubscriptionId, "alice", out var subscription));
Assert.True(await subscription.Channel.Reader.WaitToReadAsync());
}
[Fact]
public void SlowConsumerGetsAnExplicitGapInsteadOfSilentDrop()
{
var hub = new EventHub();
var subscription = hub.Subscribe("alice", null);
for (var i = 0; i < 102; i++) hub.Publish("alice", new AgentEvent(i.ToString(), "account", "chat", "message", "summary", DateTimeOffset.UtcNow));
Assert.True(hub.TryGet(subscription.SubscriptionId, "alice", out var current));
var foundGap = false;
while (current.Channel.Reader.TryRead(out var item)) foundGap |= item.Kind == "gap";
Assert.True(foundGap);
}
}
@@ -0,0 +1,39 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class EventPumpTests
{
private sealed class SourceBackend : IAgentBackend, IAgentEventSource
{
public IReadOnlyList<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public async IAsyncEnumerable<AgentEvent> ListenAsync([EnumeratorCancellation] CancellationToken ct)
{ yield return new AgentEvent("evt", "ui-current", "session", "message", "summary", DateTimeOffset.UtcNow); await Task.Delay(Timeout.Infinite, ct); }
}
[Fact]
public async Task PumpForwardsToTheConfiguredPrincipal()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = Path.Combine(dir, "credentials.json") };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new ServiceCredential("alice", new string('A', 64), ["read"], [])
}));
try
{
var hub = new EventHub(); var subscription = hub.Subscribe("alice", null);
using var pump = new EventPump(new SourceBackend(), hub, options);
await pump.StartAsync(default);
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!hub.TryGet(subscription.SubscriptionId, "alice", out var current) || !current.Channel.Reader.TryRead(out _))
await Task.Delay(10, ct.Token);
await pump.StopAsync(default);
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,49 @@
using System.Text.Json;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class OperationQueueTests
{
[Fact]
public async Task QueueCancelsBeforeExecutionRevokesAtExecutionAndNeverOverlapsTimedOutAction()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var options = new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "auth.json") };
var hash = ServiceOptions.HashToken(new string('A', 43));
var identity = new ServiceIdentity("alice", hash, ["read", "write"], ["account"]);
void Credentials(string value) => File.WriteAllText(options.CredentialFile,
JsonSerializer.Serialize(new[] { new ServiceCredential("alice", value, identity.Permissions, identity.AccountIds) }));
Credentials(hash);
var capability = new AgentCapability("send", true, true, true, true, true, "write", false, 30, null, []);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
try
{
using var store = new OperationStore(options);
using var queue = new OperationQueue(store, new ServiceSecurity(options));
await queue.StartAsync(default);
var first = queue.Submit(identity, "account", capability, "first", "one", async _ => { entered.SetResult(); await release.Task; });
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
var calls = 0;
var second = queue.Submit(identity, "account", capability, "second", "two", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
Assert.Equal("Cancelled", queue.Cancel(identity, second.Id).State);
var third = queue.Submit(identity, "account", capability, "third", "three", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
queue.Cancel(identity, first.Id);
// Cancel is cooperative: the first action still owns the lane until it really exits.
Assert.Equal("Running", store.Get(first.Id, "alice").State);
Assert.Equal("Queued", store.Get(third.Id, "alice").State);
Credentials(ServiceOptions.HashToken(new string('B', 43)));
release.SetResult();
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (store.Get(third.Id, "alice").State == "Queued") await Task.Delay(10, deadline.Token);
Assert.Equal(0, calls);
Assert.Equal("Unconfirmed", store.Get(first.Id, "alice").State);
Assert.Equal("AuthorizationRevoked", store.Get(third.Id, "alice").ErrorCode);
await queue.StopAsync(default);
}
finally { release.TrySetResult(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,46 @@
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class OperationStoreTests
{
[Fact]
public void DurableIdempotencyOwnershipCapacityAndRestartNeverReplay()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var options = new ServiceOptions { DataDirectory = directory, CredentialFile = "unused" };
try
{
string runningId;
string queuedId;
using (var store = new OperationStore(options))
{
var first = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
runningId = first.Record.Id;
Assert.True(first.Created);
var again = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
Assert.False(again.Created);
Assert.Equal(runningId, again.Record.Id);
Assert.Equal("IdempotencyConflict", Assert.Throws<ServiceException>(() =>
store.Enqueue("alice", "account", "send-text", "one", "different", true, TimeSpan.FromMinutes(1), 2)).Code);
Assert.Equal("NotFound", Assert.Throws<ServiceException>(() => store.Get(runningId, "bob")).Code);
queuedId = store.Enqueue("bob", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2).Record.Id;
Assert.Equal("QueueFull", Assert.Throws<ServiceException>(() =>
store.Enqueue("alice", "account", "send-text", "two", "digest", true, TimeSpan.FromMinutes(1), 2)).Code);
store.Transition(runningId, "Running", "executing");
}
using (var restarted = new OperationStore(options))
{
Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State);
Assert.Equal("Cancelled", restarted.Get(queuedId, "bob").State);
var again = restarted.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
Assert.False(again.Created);
Assert.Equal("Unconfirmed", again.Record.State);
restarted.Transition(runningId, "Succeeded", "late-completion");
Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State);
}
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class ServiceBoundaryTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("agent-status", true, true, true, false, false, "read", false, 30, null, []),
new("accounts-list", true, true, true, false, false, "read", false, 30, null, []),
new("sessions-list", true, true, true, true, false, "read", false, 30, null, []),
new("messages-read", true, true, true, true, false, "read", false, 30, null, []),
new("contacts-list", true, true, true, false, false, "read", false, 30, null, []),
new("send-text", true, false, false, true, true, "write", false, 30, "Write validation is pending.", [])
];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<AccountInfo>>([new("account-1", null, null, null, "fingerprint", false)]);
}
[Fact]
public async Task StaticUiAndBoundedReadOnlyPagesAreRealProductionRoutes()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) }));
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088");
var page = await client.GetAsync("/"); Assert.Equal(HttpStatusCode.OK, page.StatusCode); Assert.Contains("WxAgent", await page.Content.ReadAsStringAsync());
Assert.True(page.Headers.Contains("X-Content-Type-Options")); Assert.True(page.Headers.Contains("Content-Security-Policy"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var accounts = await client.GetFromJsonAsync<JsonElement>("/api/v1/accounts?limit=1&offset=0");
Assert.Equal(1, accounts.GetProperty("items").GetArrayLength()); Assert.False(accounts.GetProperty("hasMore").GetBoolean());
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?limit=201")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?offset=-1")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/status?token=leak")).StatusCode);
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
[Fact]
public void CredentialFilesAcceptTrayStyleCamelCaseJson()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
try
{
var token = new string('C', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new { principalId = "tray", tokenSha256 = ServiceOptions.HashToken(token), permissions = new[] { "read" }, accountIds = Array.Empty<string>() }
}));
var credentials = options.ReadCredentials();
Assert.Single(credentials);
Assert.Equal("tray", credentials[0].PrincipalId);
}
finally { Directory.Delete(dir, true); }
}
[Fact]
public void CustomAccessCredentialsHaveNoLengthLimit()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
try
{
var token = "x";
var options = new ServiceOptions
{
AccessToken = token,
CredentialFile = Path.Combine(dir, "credentials.json"),
DataDirectory = dir
};
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new ServiceCredential("custom", ServiceOptions.HashToken(token), ["read"], [])
}));
options.Validate();
Assert.Equal("custom", new ServiceSecurity(options).AuthenticateToken(token)?.PrincipalId);
}
finally { Directory.Delete(dir, true); }
}
[Fact]
public void RuntimeLogWritesFullOperationalMessagesToTheRequestedPath()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
try
{
var path = Path.Combine(dir, "wxagent.log");
RuntimeLog.Append(path, Microsoft.Extensions.Logging.LogLevel.Information, "test", "full diagnostic message");
var log = File.ReadAllText(path);
Assert.Contains("full diagnostic message", log);
Assert.Contains("[Information] test", log);
}
finally { Directory.Delete(dir, true); }
}
[Fact]
public async Task OperationListIsPrincipalScopedAndSupportsCallerAccountFiltering()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) }));
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088");
var store = app.Services.GetRequiredService<OperationStore>();
var first = store.Enqueue("p", "account-1", "read-demo", "key-1", "digest-1", false, TimeSpan.FromMinutes(1), 100).Record;
store.Transition(first.Id, "Succeeded", "complete");
var second = store.Enqueue("p", "account-2", "read-demo", "key-2", "digest-2", false, TimeSpan.FromMinutes(1), 100).Record;
store.Transition(second.Id, "Succeeded", "complete");
var other = store.Enqueue("other", "account-1", "read-demo", "key-3", "digest-3", false, TimeSpan.FromMinutes(1), 100).Record;
store.Transition(other.Id, "Succeeded", "complete");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var all = await client.GetFromJsonAsync<JsonElement>("/api/v1/operations?limit=1");
Assert.Single(all.GetProperty("items").EnumerateArray());
Assert.True(all.GetProperty("hasMore").GetBoolean());
Assert.DoesNotContain("principalId", all.GetProperty("items")[0].EnumerateObject().Select(p => p.Name), StringComparer.OrdinalIgnoreCase);
var filtered = await client.GetFromJsonAsync<JsonElement>("/api/v1/operations?accountId=account-2&limit=50");
Assert.Single(filtered.GetProperty("items").EnumerateArray());
Assert.Equal("account-2", filtered.GetProperty("items")[0].GetProperty("accountId").GetString());
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
[Fact]
public async Task DisabledTextSendDoesNotCreateAnOperation()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('B', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], []) }));
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.PostAsJsonAsync("/api/v1/operations", new
{
kind = "send-text", accountId = "account-1", targetId = "session-1", text = "test",
idempotencyKey = "send-1", confirmed = true
});
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("CapabilityDisabled", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
[Fact]
public void LoopbackRequestsCanUseLocalIdentityWithoutToken()
{
var options = new ServiceOptions { CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DataDirectory = Path.GetTempPath() };
var security = new ServiceSecurity(options);
var local = new DefaultHttpContext();
local.Request.Method = HttpMethods.Get;
local.Connection.RemoteIpAddress = IPAddress.Loopback;
var identity = security.AuthenticateRequest(local);
Assert.True(identity.LocalOnly);
Assert.True(identity.Allows("manage"));
var remote = new DefaultHttpContext();
remote.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1");
Assert.Throws<ServiceException>(() => security.AuthenticateRequest(remote));
}
[Fact]
public void ExternalBindingRequiresExplicitOptIn()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { ListenUrl = "http://192.0.2.10:5088", CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, "[]");
try { Assert.Throws<ArgumentException>(() => options.Validate()); }
finally { Directory.Delete(dir, true); }
}
[Fact]
public void MultipleTokensAreRejected()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new ServiceCredential("one", new string('A', 64), ["read"], []),
new ServiceCredential("two", new string('B', 64), ["read"], [])
}));
try { Assert.Throws<InvalidDataException>(() => options.ReadCredentials()); }
finally { Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.TestHost;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class ServiceTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("agent-status", true, true, true, false, false, "read", false, 30, null, []),
new("accounts-list", true, true, true, false, false, "read", false, 30, null, []),
new("sessions-list", true, true, true, false, false, "read", false, 30, null, []),
new("messages-read", true, true, true, true, false, "read", false, 30, null, []),
new("contacts-list", true, true, true, false, false, "read", false, 30, null, []),
new("listener-events", true, true, true, true, false, "read", false, 30, null, [])
];
public Task<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(new { online = true, wechatAvailable = false });
}
[Fact]
public async Task RealRoutesAndMcpShareCredentialsAndRejectRevokedBrowserSession()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var token = new string('A', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(directory, "credentials.json"), DataDirectory = directory };
void Rotate(string value) => File.WriteAllText(options.CredentialFile,
JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(value), ["read"], []) }));
Rotate(token);
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync();
using var client = app.GetTestClient();
client.BaseAddress = new Uri("http://localhost:5088");
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid");
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Remove("Origin");
client.DefaultRequestHeaders.Host = "evil.invalid";
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Host = null;
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");
using var initialize = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 1, method = "initialize", @params = new { protocolVersion = "2025-11-25", capabilities = new { }, clientInfo = new { name = "wxagent-integration", version = "1" } } });
Assert.Equal(HttpStatusCode.OK, initialize.StatusCode);
using var list = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 2, method = "tools/list" });
var schema = await list.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, list.StatusCode);
Assert.Contains("agent_status", schema);
Assert.Contains("inputSchema", schema);
using var call = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 3, method = "tools/call", @params = new { name = "agent_status", arguments = new { } } });
Assert.Equal(HttpStatusCode.OK, call.StatusCode);
Assert.Contains("wechatAvailable", await call.Content.ReadAsStringAsync());
client.DefaultRequestHeaders.Authorization = null;
client.DefaultRequestHeaders.Add("Origin", "http://localhost:5088");
using var login = await client.PostAsJsonAsync("/api/v1/login", new { token });
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
var cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
var result = await login.Content.ReadFromJsonAsync<JsonElement>();
client.DefaultRequestHeaders.Add("Cookie", cookie);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/api/v1/logout", null)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
Rotate(new string('B', 43));
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 4, method = "tools/list" })).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", new string('B', 43));
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
}
finally { await app.StopAsync(); Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,43 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Net.Http.Json;
using Microsoft.AspNetCore.TestHost;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class SessionTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("sessions-search", true, true, true, true, false, "read", false, 30, null, []),
new("session-current", true, true, true, true, false, "read", false, 30, null, []),
new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "not validated", [])
];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SessionSearchInfo>>([new("测试", "stable-1", true)]);
public Task<SessionInfo?> CurrentSessionAsync(CancellationToken ct) => Task.FromResult<SessionInfo?>(new("测试", "stable-1", true));
}
[Fact]
public async Task SearchCurrentAndDisabledNavigationHaveExplicitContracts()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 43); var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) }));
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var current = await client.GetFromJsonAsync<SessionInfo>("/api/v1/sessions/current"); Assert.Equal("stable-1", current!.AutomationId);
var search = await client.GetFromJsonAsync<JsonElement>("/api/v1/sessions/search?query=x&exactOnly=true"); Assert.Equal("stable-1", search.GetProperty("items")[0].GetProperty("automationId").GetString());
var scroll = await client.PostAsJsonAsync("/api/v1/sessions/scroll", new { direction = "down", pages = 1 }); Assert.Equal(HttpStatusCode.Forbidden, scroll.StatusCode);
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,45 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class SseTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
}
[Fact]
public async Task EventStreamUsesRealSseFramesAndRedactsMessageContent()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(token), ["read"], []) }));
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await client.GetAsync("/api/v1/events", HttpCompletionOption.ResponseHeadersRead);
Assert.Equal(200, (int)response.StatusCode);
var hub = app.Services.GetRequiredService<EventHub>();
hub.Publish("alice", new AgentEvent("evt-1", "ui-current", "test", "message", "Text", DateTimeOffset.UtcNow));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(timeout.Token));
Assert.Equal("id: evt-1", await reader.ReadLineAsync(timeout.Token));
Assert.Equal("event: message", await reader.ReadLineAsync(timeout.Token));
var data = await reader.ReadLineAsync(timeout.Token);
Assert.StartsWith("data: ", data);
Assert.DoesNotContain("content", data, StringComparison.OrdinalIgnoreCase);
Assert.Equal(string.Empty, await reader.ReadLineAsync(timeout.Token));
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.28" />
<ProjectReference Include="../../../node-agent/WxAgent.Service/WxAgent.Service.csproj" />
</ItemGroup>
</Project>
+45
View File
@@ -0,0 +1,45 @@
"""Offline SQLite check for the production contact SQL; no WeChat database is read."""
import re
import sqlite3
from pathlib import Path
source = (
Path(__file__).resolve().parents[2] / "node-agent/WxAgent.Core/WechatContactQuery.cs"
).read_text()
match = re.search(r'public const string Sql = """\s*(.*?)\s*""";', source, re.S)
assert match is not None
sql = match.group(1)
with sqlite3.connect(":memory:") as connection:
connection.execute(
"CREATE TABLE contact(username TEXT PRIMARY KEY, nick_name TEXT, remark TEXT, small_head_url TEXT)"
)
connection.executemany(
"INSERT INTO contact VALUES (?, ?, ?, NULL)",
[(f"wxid_{i:04}", "same display name", None) for i in range(501)]
+ [("room@chatroom", "group", None), ("literal", "O'Brien_%", None)],
)
def query(limit=201, offset=0, contains=None, groups=None):
return connection.execute(
sql, {"limit": limit, "offset": offset, "contains": contains, "groups": groups}
).fetchall()
first = query()
assert len(first) == 201
second = query(offset=200)
third = query(offset=400)
ids = [row[0] for row in first[:200] + second[:200] + third]
assert len(ids) == 503 and len(set(ids)) == 503 and ids == sorted(ids)
assert [row[0] for row in query(contains="_%")] == ["literal"]
assert query(contains="'; DROP TABLE contact;--") == []
assert [row[0] for row in query(groups=1)] == ["room@chatroom"]
assert len(query(limit=10000, groups=0)) == 502
assert len(query(limit=10000, contains="same display name")) == 501
assert query(offset=503) == []
assert connection.execute("SELECT count(*) FROM contact").fetchone()[0] == 503
print(
"contact SQL: pagination, duplicate names, literal filtering and group filtering passed"
)