using System.IO.Compression; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Reflection; using System.Text.Json; using MiaoJiZhang.Api.Controllers; using MiaoJiZhang.Api.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Testcontainers.MySql; namespace MiaoJiZhang.Api.Tests; [CollectionDefinition(Name)] public sealed class ApiCollection : ICollectionFixture { public const string Name = "api"; } public sealed class ApiFixture : IAsyncLifetime { private const string AdminUsername = "test_admin"; private const string BootstrapPassword = "test-bootstrap-password-123"; private const string AdminPassword = "test-permanent-password-456"; private readonly MySqlContainer _database = new MySqlBuilder("mysql:8.4") .WithDatabase("miaoji_test") .WithUsername("miaoji_test") .WithPassword("miaoji_test_password") .WithCleanUp(true) .Build(); public WebApplicationFactory Factory { get; private set; } = null!; public async Task InitializeAsync() { await _database.StartAsync(); Environment.SetEnvironmentVariable( "ConnectionStrings__Default", _database.GetConnectionString()); Environment.SetEnvironmentVariable( "Jwt__Secret", "test-only-jwt-secret-at-least-thirty-two-characters"); Environment.SetEnvironmentVariable("Admin__BootstrapUsername", AdminUsername); Environment.SetEnvironmentVariable("Admin__BootstrapPassword", BootstrapPassword); Environment.SetEnvironmentVariable("Admin__CookieSecure", "false"); Environment.SetEnvironmentVariable( "Push__TokenEncryptionKey", Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray())); Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", "1000"); Factory = new WebApplicationFactory().WithWebHostBuilder( builder => { builder.UseEnvironment("Development"); builder.ConfigureTestServices(services => { services.RemoveAll(); services.AddSingleton(); }); }); using var client = Factory.CreateClient(); var ping = await client.GetAsync("/api/ping"); ping.EnsureSuccessStatusCode(); await BootstrapAdminAsync(); } public async Task DisposeAsync() { Factory?.Dispose(); await _database.DisposeAsync(); Environment.SetEnvironmentVariable("ConnectionStrings__Default", null); Environment.SetEnvironmentVariable("Jwt__Secret", null); Environment.SetEnvironmentVariable("Admin__BootstrapUsername", null); Environment.SetEnvironmentVariable("Admin__BootstrapPassword", null); Environment.SetEnvironmentVariable("Admin__CookieSecure", null); Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null); Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null); } public async Task RegisterAsync(string username) { var client = Factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/auth/register", new { username, password = "test-password-123", agreedToTerms = true }); response.EnsureSuccessStatusCode(); var payload = await response.Content.ReadFromJsonAsync(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", payload.GetProperty("token").GetString()); return client; } public async Task AdminAsync() { var client = Factory.CreateClient(); var login = await client.PostAsJsonAsync( "/api/admin/auth/login", new { username = AdminUsername, password = AdminPassword }); login.EnsureSuccessStatusCode(); var payload = await login.Content.ReadFromJsonAsync(); client.DefaultRequestHeaders.Add( AdminSessionService.CsrfHeader, payload.GetProperty("csrfToken").GetString()); return client; } private async Task BootstrapAdminAsync() { using var client = Factory.CreateClient(); var login = await client.PostAsJsonAsync( "/api/admin/auth/login", new { username = AdminUsername, password = BootstrapPassword }); login.EnsureSuccessStatusCode(); var payload = await login.Content.ReadFromJsonAsync(); client.DefaultRequestHeaders.Add( AdminSessionService.CsrfHeader, payload.GetProperty("csrfToken").GetString()); var changed = await client.PutAsJsonAsync( "/api/admin/auth/password", new { currentPassword = BootstrapPassword, newPassword = AdminPassword }); changed.EnsureSuccessStatusCode(); } } [Collection(ApiCollection.Name)] public sealed class ApiIntegrationTests(ApiFixture fixture) { [Fact] public async Task PublicRuntimeDefaults_AreAvailableForProductionFlows() { using var publicClient = fixture.Factory.CreateClient(); var avatars = await publicClient.GetFromJsonAsync( "/api/public/avatars"); var personas = await publicClient.GetFromJsonAsync( "/api/public/personas"); Assert.Contains(avatars.EnumerateArray(), item => item.GetProperty("key").GetString() == "cat"); Assert.Contains(personas.EnumerateArray(), item => item.GetProperty("key").GetString() == "sassy_cat"); using var user = await fixture.RegisterAsync("runtime_defaults_user"); foreach (var type in new[] { "expense", "income" }) { var categories = await user.GetFromJsonAsync( $"/api/categories?type={type}"); Assert.Contains(categories.EnumerateArray(), item => item.GetProperty("name").GetString() == "其他"); } } [Fact] public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether() { using var owner = await fixture.RegisterAsync("owner_account"); using var stranger = await fixture.RegisterAsync("stranger_account"); var ledgers = await owner.GetFromJsonAsync("/api/ledgers"); var ledgerId = ledgers[0].GetProperty("id").GetInt64(); var incomeCategories = await owner.GetFromJsonAsync( "/api/categories?type=income"); var expenseCategories = await owner.GetFromJsonAsync( "/api/categories?type=expense"); var incomeCategoryId = incomeCategories[0].GetProperty("id").GetInt64(); var expenseCategoryId = expenseCategories[0].GetProperty("id").GetInt64(); var forbiddenLedger = await stranger.PutAsJsonAsync( $"/api/ledgers/{ledgerId}/default", new { }); Assert.Equal(HttpStatusCode.NotFound, forbiddenLedger.StatusCode); var mismatch = await owner.PostAsJsonAsync( "/api/transactions", new { ledgerId, categoryId = expenseCategoryId, type = "income", amount = 100, occurredAt = "2026-06-30T16:30:00Z", }); Assert.Equal(HttpStatusCode.BadRequest, mismatch.StatusCode); var createdResponse = await owner.PostAsJsonAsync( "/api/transactions", new { ledgerId, categoryId = incomeCategoryId, type = "income", amount = 100, note = "跨月收入", occurredAt = "2026-06-30T16:30:00Z", }); createdResponse.EnsureSuccessStatusCode(); var created = await createdResponse.Content.ReadFromJsonAsync(); var transactionId = created.GetProperty("id").GetInt64(); var july = await owner.GetFromJsonAsync( $"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}"); Assert.Equal(1, july.GetProperty("count").GetInt32()); Assert.Equal(100m, july.GetProperty("income").GetDecimal()); Assert.Equal(0m, july.GetProperty("expense").GetDecimal()); var persistedOccurredAt = july.GetProperty("days")[0] .GetProperty("items")[0] .GetProperty("occurredAt") .GetString(); Assert.EndsWith("Z", persistedOccurredAt, StringComparison.Ordinal); var recurringBudget = await owner.PutAsJsonAsync( $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}", new { categoryId = expenseCategoryId, amount = 800, recurring = true, }); recurringBudget.EnsureSuccessStatusCode(); var augustBudget = await owner.GetFromJsonAsync( $"/api/budgets?year=2026&month=8&ledgerId={ledgerId}"); Assert.True( augustBudget.GetProperty("categories")[0] .GetProperty("isRecurring") .GetBoolean()); var delete = await owner.DeleteAsync( $"/api/transactions/{transactionId}"); Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); var recycle = await owner.GetFromJsonAsync( $"/api/transactions/recycle-bin?ledgerId={ledgerId}"); Assert.Single(recycle.EnumerateArray()); var restore = await owner.PostAsync( $"/api/transactions/{transactionId}/restore", null); restore.EnsureSuccessStatusCode(); recycle = await owner.GetFromJsonAsync( $"/api/transactions/recycle-bin?ledgerId={ledgerId}"); Assert.Empty(recycle.EnumerateArray()); var export = await owner.GetByteArrayAsync("/api/users/me/export"); using var archive = new ZipArchive(new MemoryStream(export)); Assert.Contains(archive.Entries, entry => entry.Name == "transactions.csv"); Assert.Contains(archive.Entries, entry => entry.Name == "budgets.csv"); var backupEntry = Assert.Single( archive.Entries, entry => entry.Name == "backup.json"); using var reader = new StreamReader(backupEntry.Open()); var backup = await reader.ReadToEndAsync(); Assert.DoesNotContain( "passwordHash", backup, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain( "apiKey", backup, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task AccountClosure_RevokesOldToken_AndLoginCancelsDuringGracePeriod() { using var client = await fixture.RegisterAsync("closure_account"); var closure = await client.PostAsJsonAsync( "/api/users/me/closure", new { password = "test-password-123", confirmationText = "注销账号", }); Assert.Equal(HttpStatusCode.Accepted, closure.StatusCode); var oldSession = await client.GetAsync("/api/users/me"); Assert.Equal(HttpStatusCode.Unauthorized, oldSession.StatusCode); using var restored = fixture.Factory.CreateClient(); var login = await restored.PostAsJsonAsync( "/api/auth/login", new { username = "closure_account", password = "test-password-123", }); login.EnsureSuccessStatusCode(); var payload = await login.Content.ReadFromJsonAsync(); Assert.True(payload.GetProperty("accountClosureCancelled").GetBoolean()); restored.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", payload.GetProperty("token").GetString()); Assert.Equal( HttpStatusCode.OK, (await restored.GetAsync("/api/users/me")).StatusCode); } [Fact] public async Task CategoryReorder_RejectsUnknownIcons_AndReportsUseLedgerPeriod() { using var client = await fixture.RegisterAsync("report_account"); var ledgers = await client.GetFromJsonAsync("/api/ledgers"); var ledgerId = ledgers[0].GetProperty("id").GetInt64(); var invalid = await client.PostAsJsonAsync( "/api/categories", new { name = "未知图标", iconKey = "not-real", type = "expense" }); Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); async Task CreateCategory(string name, string iconKey) { var response = await client.PostAsJsonAsync( "/api/categories", new { name, iconKey, type = "expense" }); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync()) .GetProperty("id").GetInt64(); } var firstId = await CreateCategory("养车", "car"); var secondId = await CreateCategory("宠物", "pet"); var reorder = await client.PutAsJsonAsync( "/api/categories/reorder", new { type = "expense", categoryIds = new[] { secondId, firstId } }); Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode); var categories = await client.GetFromJsonAsync( "/api/categories?type=expense"); var customIds = categories.EnumerateArray() .Where(item => item.GetProperty("isCustom").GetBoolean()) .Select(item => item.GetProperty("id").GetInt64()) .ToArray(); Assert.Equal(new[] { secondId, firstId }, customIds); var weekly = await client.GetAsync( $"/api/reports/weekly?date=2026-07-19&ledgerId={ledgerId}"); weekly.EnsureSuccessStatusCode(); var yearly = await client.GetAsync( $"/api/reports/yearly?year=2026&ledgerId={ledgerId}"); yearly.EnsureSuccessStatusCode(); } [Fact] public async Task RegistrationDefaultAndUserAiPermission_AreEnforcedImmediately() { async Task SetDefaultAsync(bool enabled) { await using var scope = fixture.Factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService< MiaoJiZhang.Infrastructure.Persistence.AppDbContext>(); var config = await db.AppConfigs.SingleAsync(item => item.Key == "permission.default.ai_enabled"); config.Value = enabled ? "true" : "false"; config.Version++; config.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } await SetDefaultAsync(false); try { using var client = await fixture.RegisterAsync("permission_default_off"); var profile = await client.GetFromJsonAsync("/api/users/me"); Assert.False( profile.GetProperty("permissions").GetProperty("ai").GetBoolean()); var denied = await client.PostAsJsonAsync( "/api/chat/messages", new { content = "本月花了多少" }); Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); var error = await denied.Content.ReadFromJsonAsync(); Assert.Equal( "AI_PERMISSION_DENIED", error.GetProperty("code").GetString()); } finally { await SetDefaultAsync(true); } } [Fact] public async Task AiChatQuota_IsBoundToUserAndUsesConfiguredPeriod() { using var client = await fixture.RegisterAsync("ai_quota_account"); var profile = await client.GetFromJsonAsync("/api/users/me"); var userId = profile.GetProperty("userId").GetInt64(); using var admin = await fixture.AdminAsync(); var update = await admin.PutAsJsonAsync( $"/api/admin/users/{userId}/ai-quota", new { limit = 1, period = "week", resetUsage = true }); update.EnsureSuccessStatusCode(); var first = await client.PostAsJsonAsync( "/api/chat/messages", new { content = "本周花了多少" }); first.EnsureSuccessStatusCode(); var denied = await client.PostAsJsonAsync( "/api/chat/messages", new { content = "再查一次本周支出" }); Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); var error = await denied.Content.ReadFromJsonAsync(); Assert.Equal( "AI_CHAT_QUOTA_EXCEEDED", error.GetProperty("code").GetString()); Assert.Equal( "week", error.GetProperty("quota").GetProperty("period").GetString()); profile = await client.GetFromJsonAsync("/api/users/me"); var quota = profile.GetProperty("aiChatQuota"); Assert.Equal(1, quota.GetProperty("limit").GetInt32()); Assert.Equal(1, quota.GetProperty("used").GetInt32()); Assert.Equal(0, quota.GetProperty("remaining").GetInt32()); Assert.Equal("week", quota.GetProperty("period").GetString()); } [Fact] public async Task RecognitionClientRequestId_IsIdempotentAcrossConcurrentChannels() { using var client = await fixture.RegisterAsync("recognition_idempotency"); var ledgers = await client.GetFromJsonAsync("/api/ledgers"); var ledgerId = ledgers[0].GetProperty("id").GetInt64(); var categories = await client.GetFromJsonAsync( "/api/categories?type=expense"); var categoryId = categories[0].GetProperty("id").GetInt64(); const string clientRequestId = "wx-order-20260721-001"; var payload = new { ledgerId, categoryId, type = "expense", amount = 28.50m, note = "微信支付", occurredAt = "2026-07-21T10:30:00+08:00", source = "local_ocr", clientRequestId, }; var responses = await Task.WhenAll( client.PostAsJsonAsync("/api/transactions", payload), client.PostAsJsonAsync("/api/transactions", payload)); Assert.All(responses, response => response.EnsureSuccessStatusCode()); var created = await Task.WhenAll( responses.Select(response => response.Content.ReadFromJsonAsync())); Assert.Equal( created[0].GetProperty("id").GetInt64(), created[1].GetProperty("id").GetInt64()); Assert.Equal("local_ocr", created[0].GetProperty("source").GetString()); var month = await client.GetFromJsonAsync( $"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}"); Assert.Equal(1, month.GetProperty("count").GetInt32()); } [Fact] public async Task RecognitionBatch_PreservesThreeConsecutiveTransfers_AndIsIdempotent() { using var client = await fixture.RegisterAsync("recognition_batch_transfers"); var ledgers = await client.GetFromJsonAsync("/api/ledgers"); var ledgerId = ledgers[0].GetProperty("id").GetInt64(); var categories = await client.GetFromJsonAsync( "/api/categories?type=expense"); var categoryId = categories[0].GetProperty("id").GetInt64(); var batchId = Guid.NewGuid().ToString(); var payload = new { batchId, ledgerId, items = new[] { new { candidateId = "transfer-1", clientRequestId = "recognition-wechat-flow-1", categoryId, type = "transfer", amount = 20m, note = "转账给张三", paymentMethod = "微信", occurredAt = "2026-07-25T10:00:01+08:00", source = "recognition_ai", sourceText = "微信转账成功", transferDirection = "out", counterparty = "张三", provider = "wechat", providerTransactionId = "wx-transfer-order-1", recognitionOccurrenceId = "wx-transfer-flow-1", }, new { candidateId = "transfer-2", clientRequestId = "recognition-wechat-flow-2", categoryId, type = "transfer", amount = 20m, note = "转账给张三", paymentMethod = "微信", occurredAt = "2026-07-25T10:00:10+08:00", source = "recognition_ai", sourceText = "微信转账成功", transferDirection = "out", counterparty = "张三", provider = "wechat", providerTransactionId = "wx-transfer-order-2", recognitionOccurrenceId = "wx-transfer-flow-2", }, new { candidateId = "transfer-3", clientRequestId = "recognition-wechat-flow-3", categoryId, type = "transfer", amount = 30m, note = "转账给张三", paymentMethod = "微信", occurredAt = "2026-07-25T10:00:20+08:00", source = "recognition_ai", sourceText = "微信转账成功", transferDirection = "out", counterparty = "张三", provider = "wechat", providerTransactionId = "wx-transfer-order-3", recognitionOccurrenceId = "wx-transfer-flow-3", }, }, }; var responses = await Task.WhenAll( client.PostAsJsonAsync("/api/transactions/recognition-batch", payload), client.PostAsJsonAsync("/api/transactions/recognition-batch", payload)); Assert.All(responses, response => response.EnsureSuccessStatusCode()); var results = await Task.WhenAll( responses.Select(response => response.Content.ReadFromJsonAsync())); var first = results[0]; var retry = results[1]; Assert.Equal(3, first.GetArrayLength()); Assert.Equal(3, retry.GetArrayLength()); Assert.Equal( first.EnumerateArray() .Select(item => item.GetProperty("transaction").GetProperty("id").GetInt64()), retry.EnumerateArray() .Select(item => item.GetProperty("transaction").GetProperty("id").GetInt64())); var month = await client.GetFromJsonAsync( $"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}"); Assert.Equal(3, month.GetProperty("count").GetInt32()); Assert.Equal(70m, month.GetProperty("expense").GetDecimal()); Assert.All( month.GetProperty("days")[0].GetProperty("items").EnumerateArray(), item => { Assert.Equal("transfer", item.GetProperty("type").GetString()); Assert.Equal("out", item.GetProperty("transferDirection").GetString()); Assert.Equal("张三", item.GetProperty("counterparty").GetString()); }); } [Fact] public async Task TransferDirections_AffectStatisticsAndBudgets_AndRecognitionIdsAreIdempotent() { using var client = await fixture.RegisterAsync("transfer_semantics"); var ledgers = await client.GetFromJsonAsync("/api/ledgers"); var ledgerId = ledgers[0].GetProperty("id").GetInt64(); var expenseCategories = await client.GetFromJsonAsync( "/api/categories?type=expense"); var incomeCategories = await client.GetFromJsonAsync( "/api/categories?type=income"); var expenseCategoryId = expenseCategories[0].GetProperty("id").GetInt64(); var incomeCategoryId = incomeCategories[0].GetProperty("id").GetInt64(); var totalBudget = await client.PutAsJsonAsync( $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}", new { categoryId = (long?)null, amount = 100m }); totalBudget.EnsureSuccessStatusCode(); var categoryBudget = await client.PutAsJsonAsync( $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}", new { categoryId = expenseCategoryId, amount = 80m }); categoryBudget.EnsureSuccessStatusCode(); var transferOut = new { ledgerId, categoryId = expenseCategoryId, type = "transfer", transferDirection = "out", counterparty = "李四", amount = 40m, occurredAt = "2026-07-25T11:00:00+08:00", source = "accessibility", clientRequestId = "wx-event-out-a", provider = "wechat", providerTransactionId = "wx-order-out-001", recognitionOccurrenceId = "wx-occurrence-out-001", }; var outResponse = await client.PostAsJsonAsync("/api/transactions", transferOut); outResponse.EnsureSuccessStatusCode(); var outTransaction = await outResponse.Content.ReadFromJsonAsync(); var providerRetry = await client.PostAsJsonAsync( "/api/transactions", new { transferOut.ledgerId, transferOut.categoryId, transferOut.type, transferOut.transferDirection, transferOut.counterparty, transferOut.amount, transferOut.occurredAt, transferOut.source, clientRequestId = "wx-event-out-b", transferOut.provider, transferOut.providerTransactionId, recognitionOccurrenceId = "wx-occurrence-out-changed", }); providerRetry.EnsureSuccessStatusCode(); var retried = await providerRetry.Content.ReadFromJsonAsync(); Assert.Equal( outTransaction.GetProperty("id").GetInt64(), retried.GetProperty("id").GetInt64()); var inResponse = await client.PostAsJsonAsync( "/api/transactions", new { ledgerId, categoryId = incomeCategoryId, type = "transfer", transferDirection = "in", counterparty = "李四", amount = 75m, occurredAt = "2026-07-25T11:05:00+08:00", source = "accessibility", clientRequestId = "wx-event-in-a", recognitionOccurrenceId = "wx-occurrence-in-001", }); inResponse.EnsureSuccessStatusCode(); var inTransaction = await inResponse.Content.ReadFromJsonAsync(); var occurrenceRetry = await client.PostAsJsonAsync( "/api/transactions", new { ledgerId, categoryId = incomeCategoryId, type = "transfer", transferDirection = "in", counterparty = "李四", amount = 75m, occurredAt = "2026-07-25T11:05:00+08:00", source = "accessibility", clientRequestId = "wx-event-in-b", recognitionOccurrenceId = "wx-occurrence-in-001", }); occurrenceRetry.EnsureSuccessStatusCode(); var occurrenceRetried = await occurrenceRetry.Content.ReadFromJsonAsync(); Assert.Equal( inTransaction.GetProperty("id").GetInt64(), occurrenceRetried.GetProperty("id").GetInt64()); var month = await client.GetFromJsonAsync( $"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}"); Assert.Equal(2, month.GetProperty("count").GetInt32()); Assert.Equal(40m, month.GetProperty("expense").GetDecimal()); Assert.Equal(75m, month.GetProperty("income").GetDecimal()); var stats = await client.GetFromJsonAsync( $"/api/transactions/stats?year=2026&month=7&ledgerId={ledgerId}"); Assert.Equal(40m, stats.GetProperty("totalExpense").GetDecimal()); Assert.Equal(75m, stats.GetProperty("totalIncome").GetDecimal()); var budgets = await client.GetFromJsonAsync( $"/api/budgets?year=2026&month=7&ledgerId={ledgerId}"); Assert.Equal(40m, budgets.GetProperty("total").GetProperty("spent").GetDecimal()); var expenseBudget = Assert.Single( budgets.GetProperty("categories").EnumerateArray(), item => item.GetProperty("categoryId").GetInt64() == expenseCategoryId); Assert.Equal(40m, expenseBudget.GetProperty("spent").GetDecimal()); } [Fact] public async Task AdminSessions_EnforceCsrfViewerRevocationAndAudit() { using var superAdmin = await fixture.AdminAsync(); var suffix = Guid.NewGuid().ToString("N")[..10]; var username = $"viewer_{suffix}"; const string initialPassword = "init06"; const string permanentPassword = "new006"; var shortPasswordResponse = await superAdmin.PostAsJsonAsync( "/api/admin/security/accounts", new { username = $"{username}_short", password = "12345", role = "viewer" }); Assert.Equal(HttpStatusCode.BadRequest, shortPasswordResponse.StatusCode); var createdResponse = await superAdmin.PostAsJsonAsync( "/api/admin/security/accounts", new { username, password = initialPassword, role = "viewer" }); createdResponse.EnsureSuccessStatusCode(); var created = await createdResponse.Content.ReadFromJsonAsync(); var viewerId = created.GetProperty("id").GetInt64(); using var viewer = fixture.Factory.CreateClient(); var login = await viewer.PostAsJsonAsync( "/api/admin/auth/login", new { username, password = initialPassword }); login.EnsureSuccessStatusCode(); var loginPayload = await login.Content.ReadFromJsonAsync(); Assert.True(loginPayload.GetProperty("mustChangePassword").GetBoolean()); var missingCsrf = await viewer.PutAsJsonAsync( "/api/admin/auth/password", new { currentPassword = initialPassword, newPassword = permanentPassword }); Assert.Equal(HttpStatusCode.Unauthorized, missingCsrf.StatusCode); viewer.DefaultRequestHeaders.Add( AdminSessionService.CsrfHeader, loginPayload.GetProperty("csrfToken").GetString()); var changed = await viewer.PutAsJsonAsync( "/api/admin/auth/password", new { currentPassword = initialPassword, newPassword = permanentPassword }); changed.EnsureSuccessStatusCode(); var changedPayload = await changed.Content.ReadFromJsonAsync(); viewer.DefaultRequestHeaders.Remove(AdminSessionService.CsrfHeader); viewer.DefaultRequestHeaders.Add( AdminSessionService.CsrfHeader, changedPayload.GetProperty("csrfToken").GetString()); Assert.Equal( HttpStatusCode.OK, (await viewer.GetAsync("/api/admin/dashboard")).StatusCode); var writeDenied = await viewer.PostAsJsonAsync( "/api/admin/configs", new { key = $"viewer.denied.{suffix}", value = "no" }); Assert.Equal(HttpStatusCode.Forbidden, writeDenied.StatusCode); var revoked = await superAdmin.PostAsync( $"/api/admin/security/accounts/{viewerId}/revoke-sessions", null); Assert.Equal(HttpStatusCode.NoContent, revoked.StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, (await viewer.GetAsync("/api/admin/dashboard")).StatusCode); var audit = await superAdmin.GetFromJsonAsync( $"/api/admin/security/audit?username={username}"); Assert.True(audit.GetProperty("total").GetInt32() >= 3); var entries = audit.GetProperty("list").EnumerateArray().ToList(); Assert.Contains(entries, entry => entry.GetProperty("action").GetString() == "post.api.admin.auth.login" && entry.GetProperty("success").GetBoolean()); Assert.Contains(entries, entry => entry.GetProperty("path").GetString() == "/api/admin/configs" && !entry.GetProperty("success").GetBoolean()); } } public sealed class ChinaClockTests { [Fact] public void ShanghaiMonthRange_MapsUtcBoundaryCorrectly() { var (start, end) = ChinaClock.MonthRangeUtc(2026, 7); Assert.Equal( new DateTime(2026, 6, 30, 16, 0, 0, DateTimeKind.Utc), start); Assert.Equal( new DateTime(2026, 7, 31, 16, 0, 0, DateTimeKind.Utc), end); } [Fact] public void ShanghaiWeekAndYearRanges_UseLocalCalendarBoundaries() { var (weekStart, weekEnd) = ChinaClock.WeekRangeUtc( new DateTime(2026, 7, 19)); Assert.Equal( new DateTime(2026, 7, 12, 16, 0, 0, DateTimeKind.Utc), weekStart); Assert.Equal( new DateTime(2026, 7, 19, 16, 0, 0, DateTimeKind.Utc), weekEnd); var (yearStart, yearEnd) = ChinaClock.YearRangeUtc(2026); Assert.Equal( new DateTime(2025, 12, 31, 16, 0, 0, DateTimeKind.Utc), yearStart); Assert.Equal( new DateTime(2026, 12, 31, 16, 0, 0, DateTimeKind.Utc), yearEnd); } } public sealed class ImageParseResultTests { private static readonly MethodInfo ParseMethod = typeof(OpenAiVisionClient).GetMethod( "ParseImageResults", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("Image parser not found"); [Fact] public void FullTimes_AreParsedPerBill_AndDateOnlyFallsBack() { const string payload = """ { "bills": [ { "type": "expense", "amount": 18, "categoryName": "饮品", "note": "奶茶", "occurredAt": "2026-07-18T14:30:00+08:00" }, { "type": "income", "amount": 100, "categoryName": "兼职", "note": "稿费", "occurredAt": "2026-07-17T09:15:00+08:00" }, { "type": "expense", "amount": 30, "categoryName": "餐饮", "note": "午饭", "occurredAt": "2026-07-16" } ] } """; var results = Assert.IsAssignableFrom>( ParseMethod.Invoke(null, [payload])); Assert.Equal(3, results.Count); Assert.Equal( new DateTime(2026, 7, 18, 6, 30, 0, DateTimeKind.Utc), results[0].OccurredAt); Assert.Equal( new DateTime(2026, 7, 17, 1, 15, 0, DateTimeKind.Utc), results[1].OccurredAt); Assert.Null(results[2].OccurredAt); } } public sealed class RecognitionBatchActionParserTests { private static readonly MethodInfo ParseMethod = typeof(OpenAiVisionClient).GetMethod( "ParseRecognitionBatchActions", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("Recognition batch parser not found"); [Fact] public void Actions_AreParsedFromFencedJson_AndInvalidActionsAreIgnored() { const string payload = """ result: ```json { "actions": [ { "action": "update", "actionId": "a1", "candidateId": "candidate-1", "amount": 20, "confidence": 1.4, "reason": "修正金额" }, { "action": "merge", "candidateId": "candidate-2" } ] } ``` """; var actions = Assert.IsAssignableFrom>( ParseMethod.Invoke(null, [payload])); var action = Assert.Single(actions); Assert.Equal("update", action.Action); Assert.Equal("candidate-1", action.CandidateId); Assert.Equal(20m, action.Amount); Assert.Equal(1, action.Confidence); } } public sealed class RecognitionAmountUpdateEvidenceTests { private static readonly IReadOnlyDictionary EvidenceOwners = new Dictionary { ["evidence-a"] = "candidate-a", ["evidence-b"] = "candidate-b" }; [Fact] public void AmountUpdateRequiresLinkedEvidenceAndHighConfidence() { Assert.False(ParseController.CanApplyAmountUpdate( "candidate-a", null, 0.99, EvidenceOwners)); Assert.False(ParseController.CanApplyAmountUpdate( "candidate-a", "evidence-a", 0.89, EvidenceOwners)); Assert.False(ParseController.CanApplyAmountUpdate( "candidate-a", "evidence-b", 0.99, EvidenceOwners)); Assert.True(ParseController.CanApplyAmountUpdate( "candidate-a", "evidence-a", 0.9, EvidenceOwners)); } } public sealed class BudgetRecommendationValidationTests { [Fact] public void Draft_IsClampedToSpent_AndKeepsUnmentionedCategories() { var contexts = new Dictionary { [1] = new(1, "餐饮", "food", "coral", 80, 120, "high"), [2] = new(2, "交通", "transport", "teal", 20, 60, "medium"), }; var request = new MiaoJiZhang.Api.Contracts.RefineBudgetRecommendationRequest( null, 2026, 7, "餐饮降到十元,其他不变", new MiaoJiZhang.Api.Contracts.BudgetDraftRequest( 150, [ new(1, 100), new(2, 50), ])); const string arguments = """ { "suggestedTotal": 20, "items": [ { "categoryId": 1, "amount": 10 } ], "summary": "已降低餐饮预算" } """; var result = BudgetRecommendationService.ValidateToolDraft( request, contexts, new Dictionary { [1] = 100, [2] = 50 }, 100, arguments, out var summary); Assert.Equal("已降低餐饮预算", summary); Assert.Equal(130, result.SuggestedTotal); Assert.Equal(80, result.Items.Single(item => item.CategoryId == 1).SuggestedAmount); Assert.Equal(50, result.Items.Single(item => item.CategoryId == 2).SuggestedAmount); Assert.Contains(result.Warnings!, warning => warning.Contains("不能低于已花")); Assert.Contains(result.Warnings!, warning => warning.Contains("保留原值")); } }