Initial project import
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user