410 lines
17 KiB
C#
410 lines
17 KiB
C#
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"));
|
|
}
|
|
}
|
|
}
|