This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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<InvalidOperationException>(() =>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user