964 lines
40 KiB
C#
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("保留原值"));
|
|
}
|
|
}
|