Files

964 lines
40 KiB
C#

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<ApiFixture>
{
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<Program> 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<Program>().WithWebHostBuilder(
builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ILlmClient>();
services.AddSingleton<ILlmClient, NullLlmClient>();
});
});
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<HttpClient> 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<JsonElement>();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
payload.GetProperty("token").GetString());
return client;
}
public async Task<HttpClient> 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<JsonElement>();
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<JsonElement>();
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<JsonElement>(
"/api/public/avatars");
var personas = await publicClient.GetFromJsonAsync<JsonElement>(
"/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<JsonElement>(
$"/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<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var incomeCategories = await owner.GetFromJsonAsync<JsonElement>(
"/api/categories?type=income");
var expenseCategories = await owner.GetFromJsonAsync<JsonElement>(
"/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<JsonElement>();
var transactionId = created.GetProperty("id").GetInt64();
var july = await owner.GetFromJsonAsync<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>();
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<JsonElement>("/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<long> CreateCategory(string name, string iconKey)
{
var response = await client.PostAsJsonAsync(
"/api/categories",
new { name, iconKey, type = "expense" });
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<JsonElement>())
.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<JsonElement>(
"/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<JsonElement>("/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<JsonElement>();
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<JsonElement>("/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<JsonElement>();
Assert.Equal(
"AI_CHAT_QUOTA_EXCEEDED",
error.GetProperty("code").GetString());
Assert.Equal(
"week",
error.GetProperty("quota").GetProperty("period").GetString());
profile = await client.GetFromJsonAsync<JsonElement>("/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<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var categories = await client.GetFromJsonAsync<JsonElement>(
"/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<JsonElement>()));
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<JsonElement>(
$"/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<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var categories = await client.GetFromJsonAsync<JsonElement>(
"/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<JsonElement>()));
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<JsonElement>(
$"/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<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var expenseCategories = await client.GetFromJsonAsync<JsonElement>(
"/api/categories?type=expense");
var incomeCategories = await client.GetFromJsonAsync<JsonElement>(
"/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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
Assert.Equal(
inTransaction.GetProperty("id").GetInt64(),
occurrenceRetried.GetProperty("id").GetInt64());
var month = await client.GetFromJsonAsync<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>(
$"/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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>(
$"/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<IReadOnlyList<ImageParseResult>>(
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<IReadOnlyList<RecognitionBatchModelAction>>(
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<string, string?> EvidenceOwners =
new Dictionary<string, string?>
{
["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<long, BudgetRecommendationService.CategoryContext>
{
[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<long, decimal> { [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("保留原值"));
}
}