using System.IO.Compression; using System.Reflection; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; using YMhut.Box.Core.App; using YMhut.Box.Core.Feedback; using YMhut.Box.Core.Logging; using YMhut.Box.Core.System; using YMhut.Box.Core.Tools; namespace YMhut.Box.Tests; [TestClass] public sealed class FeedbackServiceTests { [TestMethod] public void FeedbackCodeCreatesServerCompatibleCode() { var code = FeedbackCode.Create(DateTimeOffset.Parse("2026-06-04T10:00:00+08:00")); Assert.IsTrue(Regex.IsMatch(code, "^FB-20260604-[A-F0-9]{6}$")); Assert.AreEqual("FB-20260604-ABC123", FeedbackCode.Normalize(" fb-20260604-abc123 ")); Assert.IsTrue(FeedbackCode.IsValid("fb-20260604-abc123")); Assert.IsFalse(FeedbackCode.IsValid("FB-20260604-XYZ123")); } [TestMethod] public async Task FeedbackPackageIncludesSelectedAttachmentsAndLocalizedLogs() { var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N")); var paths = AppPaths.ForCurrentUser(tempRoot); var logs = new JsonFileLogService(paths); var service = new FeedbackPackageService(logs, new FakeSystemMetricsService(), new ToolCatalog()); await logs.WriteAsync("Information", "navigation", "Open toolbox", "https://example.com/private?token=secret"); var package = await service.BuildPackageAsync(new FeedbackRequest( "同步状态卡住", "issue", "major", "dev@example.com", "首页一直显示后台校验中。", IncludeTodayLogs: true, IncludeToolStatus: true, IncludeSystemSummary: true, Language: "zh-CN", FeedbackCode: "FB-20260604-ABC123")); try { Assert.IsTrue(File.Exists(package.PackagePath)); Assert.IsGreaterThan(0, package.PackageBytes); Assert.AreEqual( Path.GetFullPath(FeedbackPackageService.FeedbackPackagesRoot()), Path.GetFullPath(Path.GetDirectoryName(package.PackagePath)!)); Assert.IsTrue(package.IncludedFiles.ContainsKey("feedback.json")); Assert.IsTrue(package.IncludedFiles.ContainsKey("tool-status.json")); Assert.IsTrue(package.IncludedFiles.ContainsKey("system-summary.json")); Assert.IsTrue(package.IncludedFiles.Keys.Any(name => name.StartsWith("logs-", StringComparison.OrdinalIgnoreCase))); Assert.IsTrue(package.SummaryText.Contains("首页一直显示后台校验中。", StringComparison.Ordinal)); Assert.IsTrue(package.SummaryText.Contains("FB-20260604-ABC123", StringComparison.Ordinal)); using var archive = ZipFile.OpenRead(package.PackagePath); Assert.IsNotNull(archive.GetEntry("feedback.json")); Assert.IsNotNull(archive.GetEntry("summary.txt")); var feedbackJson = ReadEntry(archive.GetEntry("feedback.json")!); using var feedbackDocument = JsonDocument.Parse(feedbackJson); Assert.AreEqual("FB-20260604-ABC123", feedbackDocument.RootElement.GetProperty("feedbackCode").GetString()); Assert.AreEqual("FB-20260604-ABC123", feedbackDocument.RootElement.GetProperty("request").GetProperty("feedbackCode").GetString()); var logsEntry = archive.Entries.Single(entry => entry.FullName.StartsWith("logs-", StringComparison.OrdinalIgnoreCase)); var logsJson = ReadEntry(logsEntry); using var logsDocument = JsonDocument.Parse(logsJson); var firstLog = logsDocument.RootElement[0]; Assert.AreEqual("导航", firstLog.GetProperty("display").GetProperty("category").GetString()); Assert.AreEqual("打开工具箱", firstLog.GetProperty("display").GetProperty("message").GetString()); Assert.AreEqual("远程服务", firstLog.GetProperty("display").GetProperty("detail").GetString()); Assert.AreEqual("远程服务", firstLog.GetProperty("raw").GetProperty("detail").GetString()); Assert.IsFalse(logsJson.Contains("example.com/private", StringComparison.OrdinalIgnoreCase)); var toolStatus = ReadEntry(archive.GetEntry("tool-status.json")!); StringAssert.Contains(toolStatus, "\"id\": \"compression_codec\""); StringAssert.Contains(toolStatus, "\"exists\": true"); } finally { DeleteIfExists(package.PackagePath); Directory.Delete(tempRoot, recursive: true); } } [TestMethod] public async Task FeedbackPackageCryptoRoundTripsLocalZip() { var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var sourceZip = Path.Combine(tempRoot, "feedback.zip"); var decryptedZip = Path.Combine(tempRoot, "feedback-decrypted.zip"); try { using (var archive = ZipFile.Open(sourceZip, ZipArchiveMode.Create)) { var entry = archive.CreateEntry("summary.txt"); await using var stream = entry.Open(); await using var writer = new StreamWriter(stream); await writer.WriteAsync("反馈包加密测试"); } var encrypted = await FeedbackPackageCrypto.EncryptPackageAsync(sourceZip); Assert.IsTrue(File.Exists(encrypted.PackagePath)); Assert.AreEqual(".ymfb", Path.GetExtension(encrypted.PackagePath)); Assert.IsGreaterThan(new FileInfo(sourceZip).Length, encrypted.PackageBytes); await FeedbackPackageCrypto.DecryptPackageAsync(encrypted.PackagePath, decryptedZip); CollectionAssert.AreEqual( await File.ReadAllBytesAsync(sourceZip), await File.ReadAllBytesAsync(decryptedZip)); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [TestMethod] public async Task FeedbackPackageRejectsScreenshotOverFiveMegabytes() { var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N")); var paths = AppPaths.ForCurrentUser(tempRoot); var logs = new JsonFileLogService(paths); var service = new FeedbackPackageService(logs, new FakeSystemMetricsService(), new ToolCatalog()); var screenshot = Path.Combine(tempRoot, "large.png"); await File.WriteAllBytesAsync(screenshot, new byte[FeedbackPackageService.MaxScreenshotBytes + 1]); try { await Assert.ThrowsExactlyAsync(() => service.BuildPackageAsync(new FeedbackRequest( "截图过大", "issue", "normal", string.Empty, "测试截图限制。", IncludeTodayLogs: false, IncludeToolStatus: false, IncludeSystemSummary: false, ScreenshotPath: screenshot))); } finally { Directory.Delete(tempRoot, recursive: true); } } [TestMethod] public void FeedbackSubmissionSignatureIsStable() { var signature = FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"); Assert.AreEqual(64, signature.Length); Assert.IsTrue(signature.All(character => Uri.IsHexDigit(character) && !char.IsUpper(character))); Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}")); } [TestMethod] public void FeedbackStatusResponseParsesExtendedServerFields() { var response = ParseStatusResponse(""" { "ok": true, "code": "FB-20260604-ABC123", "status": "investigating", "statusLabel": "处理中", "statusDetail": "已收到日志,正在定位。", "category": "issue", "priority": "major", "hasReply": true, "reply": "请先保留现场文件。", "receivedAt": "2026-06-04T10:00:00+08:00", "updatedAt": "2026-06-04T10:30:00+08:00", "mailSent": true } """); Assert.IsTrue(response.Ok); Assert.AreEqual("FB-20260604-ABC123", response.Code); Assert.AreEqual("investigating", response.Status); Assert.AreEqual("处理中", response.StatusLabel); Assert.AreEqual("已收到日志,正在定位。", response.StatusDetail); Assert.AreEqual("issue", response.Category); Assert.AreEqual("major", response.Priority); Assert.IsTrue(response.HasReply); Assert.AreEqual("请先保留现场文件。", response.Reply); Assert.AreEqual("2026-06-04T10:30:00+08:00", response.UpdatedAt); Assert.IsTrue(response.MailSent); } [TestMethod] public async Task FeedbackRecordStoreReadsOldRecordsWithoutExtendedFields() { var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N")); var storagePath = Path.Combine(tempRoot, "feedback-records.json"); Directory.CreateDirectory(tempRoot); await File.WriteAllTextAsync(storagePath, """ [ { "code": "FB-20260604-ABC123", "title": "Old record", "type": "issue", "severity": "normal", "createdAtUtc": "2026-06-04T02:00:00+00:00", "updatedAtUtc": "2026-06-04T02:10:00+00:00", "state": "Sent", "statusLabel": "Sent", "lastMessage": "The service received it." } ] """); try { var record = await new FeedbackRecordStore(storagePath).FindAsync("fb-20260604-abc123"); Assert.IsNotNull(record); Assert.AreEqual("FB-20260604-ABC123", record.Code); Assert.AreEqual("Sent", record.StatusLabel); Assert.IsNull(record.ServiceStatusDetail); Assert.IsNull(record.ServiceCategory); Assert.IsNull(record.ServicePriority); Assert.IsNull(record.ServiceMailSent); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [TestMethod] public async Task FeedbackRecordStoreKeepsStatusMessageAndPublicReplySeparate() { var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N")); var storagePath = Path.Combine(tempRoot, "feedback-records.json"); var store = new FeedbackRecordStore(storagePath); var now = DateTimeOffset.Parse("2026-06-04T03:00:00Z"); try { await store.UpsertAsync(new FeedbackRecord( "FB-20260604-ABC123", "Status query", "issue", "major", now, now, FeedbackRecordState.StatusUpdated, "处理中", "已收到日志,正在定位。", LastQueryAtUtc: now, ServiceStatus: "investigating", PublicReply: "请先保留现场文件。", ServiceStatusDetail: "已收到日志,正在定位。", ServiceCategory: "issue", ServicePriority: "major", ServiceMailSent: true, ServiceReceivedAt: "2026-06-04T10:00:00+08:00", ServiceUpdatedAt: "2026-06-04T10:30:00+08:00")); var record = await store.FindAsync("FB-20260604-ABC123"); Assert.IsNotNull(record); Assert.AreEqual("已收到日志,正在定位。", record.LastMessage); Assert.IsFalse(record.LastMessage.Contains("请先保留现场文件。", StringComparison.Ordinal)); Assert.AreEqual("请先保留现场文件。", record.PublicReply); Assert.AreEqual("investigating", record.ServiceStatus); Assert.AreEqual("issue", record.ServiceCategory); Assert.AreEqual("major", record.ServicePriority); Assert.IsTrue(record.ServiceMailSent); Assert.AreEqual("2026-06-04T10:30:00+08:00", record.ServiceUpdatedAt); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [TestMethod] public async Task FeedbackRecordStoreArchivesOldRecordsAndKeepsThemQueryable() { var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N")); var storagePath = Path.Combine(tempRoot, "feedback-records.json"); var store = new FeedbackRecordStore(storagePath); var now = DateTimeOffset.Parse("2026-06-05T12:00:00Z"); var oldCreated = now.AddDays(-11); try { await store.UpsertAsync(new FeedbackRecord( "FB-20260525-ABC123", "旧反馈", "issue", "normal", oldCreated, oldCreated, FeedbackRecordState.Sent, "发送成功", "服务端已接收。", PackagePath: Path.Combine(tempRoot, "feedback.zip"), PackageSha256: new string('a', 64), PackageBytes: 128, SubmittedAtUtc: oldCreated)); var archived = await store.ArchiveOlderThanAsync(TimeSpan.FromDays(10), now); var record = archived.Single(); Assert.IsTrue(record.IsArchived); Assert.AreEqual(FeedbackRecordState.Archived, record.State); Assert.AreEqual("已归档", record.StatusLabel); Assert.IsNotNull(record.ArchivedAtUtc); var updated = await store.UpsertAsync(record with { State = FeedbackRecordState.StatusUpdated, StatusLabel = "状态已更新", LastMessage = "当前状态:已处理。", UpdatedAtUtc = now.AddMinutes(5), LastQueryAtUtc = now.AddMinutes(5) }); Assert.IsTrue(updated.IsArchived); Assert.AreEqual(FeedbackRecordState.StatusUpdated, updated.State); Assert.AreEqual("状态已更新", updated.StatusLabel); Assert.AreEqual("当前状态:已处理。", updated.LastMessage); var reloaded = await new FeedbackRecordStore(storagePath).FindAsync("fb-20260525-abc123"); Assert.IsNotNull(reloaded); Assert.IsTrue(reloaded.IsArchived); Assert.AreEqual("FB-20260525-ABC123", reloaded.Code); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } private static string ReadEntry(ZipArchiveEntry entry) { using var stream = entry.Open(); using var reader = new StreamReader(stream); return reader.ReadToEnd(); } private static FeedbackStatusResponse ParseStatusResponse(string json) { var method = typeof(FeedbackSubmissionService).GetMethod("ParseStatusResponse", BindingFlags.NonPublic | BindingFlags.Static); Assert.IsNotNull(method); return (FeedbackStatusResponse)method.Invoke(null, new object?[] { json })!; } private static void DeleteIfExists(string path) { if (File.Exists(path)) { File.Delete(path); } } private sealed class FakeSystemMetricsService : ISystemMetricsService { public SystemMetricsSnapshot Capture() { return new SystemMetricsSnapshot( "TEST-PC", "Windows Test", 8, 1024, 2048, 42.5, 16UL * 1024 * 1024 * 1024, 8UL * 1024 * 1024 * 1024, 8UL * 1024 * 1024 * 1024, 50, 1.2, TimeSpan.FromMinutes(12), 24, 512, 256UL * 1024 * 1024 * 1024, 128UL * 1024 * 1024 * 1024, 128UL * 1024 * 1024 * 1024, 50, true, ".NET 10.0", DateTimeOffset.Parse("2026-06-04T10:00:00+08:00")); } } }