Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -0,0 +1,532 @@
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.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 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__Key",
"test-only-admin-key-at-least-24-characters");
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();
}
public async Task DisposeAsync()
{
Factory?.Dispose();
await _database.DisposeAsync();
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
Environment.SetEnvironmentVariable("Jwt__Secret", null);
Environment.SetEnvironmentVariable("Admin__Key", 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;
}
}
[Collection(ApiCollection.Name)]
public sealed class ApiIntegrationTests(ApiFixture fixture)
{
[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 = fixture.Factory.CreateClient();
admin.DefaultRequestHeaders.Add(
"X-Admin-Key",
"test-only-admin-key-at-least-24-characters");
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());
}
}
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 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("保留原值"));
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MiaoJiZhang.Api\MiaoJiZhang.Api.csproj" />
</ItemGroup>
</Project>