Files
jizhi/backend/MiaoJiZhang.Api.Tests/PushIntegrationTests.cs
T

307 lines
14 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MiaoJiZhang.Api.Tests;
[Collection(ApiCollection.Name)]
public sealed class PushIntegrationTests(ApiFixture fixture)
{
[Fact]
public async Task Preferences_DefaultOff_AndPersistAllCategories()
{
using var client = await fixture.RegisterAsync("push_preferences");
var defaults = await client.GetFromJsonAsync<JsonElement>("/api/push/preferences");
Assert.False(defaults.GetProperty("system").GetBoolean());
Assert.False(defaults.GetProperty("budget").GetBoolean());
Assert.False(defaults.GetProperty("operations").GetBoolean());
var update = await client.PutAsJsonAsync(
"/api/push/preferences",
new { system = true, budget = false, operations = true });
update.EnsureSuccessStatusCode();
var persisted = await client.GetFromJsonAsync<JsonElement>("/api/push/preferences");
Assert.True(persisted.GetProperty("system").GetBoolean());
Assert.False(persisted.GetProperty("budget").GetBoolean());
Assert.True(persisted.GetProperty("operations").GetBoolean());
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var userId = await db.Users.Where(user => user.Username == "push_preferences")
.Select(user => user.Id).SingleAsync();
var preferences = await db.UserPushPreferences.Where(item => item.UserId == userId)
.ToDictionaryAsync(item => item.Category, item => item.IsEnabled);
Assert.Equal(3, preferences.Count);
Assert.True(preferences[PushCategories.System]);
Assert.False(preferences[PushCategories.Budget]);
Assert.True(preferences[PushCategories.Operations]);
}
[Fact]
public async Task DeviceRegistration_RebindsInstallation_AndRotatesAnonymousUnbindToken()
{
using var firstUser = await fixture.RegisterAsync("push_device_first");
using var secondUser = await fixture.RegisterAsync("push_device_second");
await EnableSystemAsync(firstUser);
await EnableSystemAsync(secondUser);
var installationId = Guid.NewGuid().ToString();
var firstRegistration = await RegisterDeviceAsync(
firstUser,
installationId,
"first-device-token");
var firstPayload = await firstRegistration.Content.ReadFromJsonAsync<JsonElement>();
var deviceId = firstPayload.GetProperty("deviceId").GetInt64();
var oldUnbindToken = firstPayload.GetProperty("unbindToken").GetString()!;
var secondRegistration = await RegisterDeviceAsync(
secondUser,
installationId,
"second-device-token");
var secondPayload = await secondRegistration.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(deviceId, secondPayload.GetProperty("deviceId").GetInt64());
var newUnbindToken = secondPayload.GetProperty("unbindToken").GetString()!;
Assert.NotEqual(oldUnbindToken, newUnbindToken);
using var anonymous = fixture.Factory.CreateClient();
await DeleteWithUnbindToken(anonymous, installationId, oldUnbindToken);
Assert.True(await DeviceExists(deviceId));
var unauthenticated = await anonymous.DeleteAsync($"/api/push/devices/{installationId}");
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
Assert.True(await DeviceExists(deviceId));
var removed = await DeleteWithUnbindToken(anonymous, installationId, newUnbindToken);
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
Assert.False(await DeviceExists(deviceId));
}
[Fact]
public async Task BudgetPush_CreatesMessagesOnlyWhenCrossingEnabledThresholds()
{
using var client = await fixture.RegisterAsync("push_budget_crossings");
var preference = await client.PutAsJsonAsync(
"/api/push/preferences",
new { system = false, budget = true, operations = false });
preference.EnsureSuccessStatusCode();
var (userId, ledgerId, categoryId) = await FinanceContext(client);
await PutBudget(client, ledgerId, categoryId, 100);
await CreateExpense(client, ledgerId, categoryId, 79, "budget-crossing-79");
Assert.Equal((0, 0), await BudgetCounts(userId));
await CreateExpense(client, ledgerId, categoryId, 1, "budget-crossing-80");
Assert.Equal((1, 1), await BudgetCounts(userId));
await CreateExpense(client, ledgerId, categoryId, 20, "budget-crossing-100");
Assert.Equal((2, 2), await BudgetCounts(userId));
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messages = await db.PushMessages.Where(item => item.TargetUserId == userId && item.Source == "budget")
.OrderBy(item => item.Id).ToListAsync();
Assert.Equal("预算接近上限", messages[0].Title);
Assert.Equal("预算已达到上限", messages[1].Title);
Assert.All(messages, item => Assert.Equal(PushActions.Budget, item.Action));
}
[Fact]
public async Task BudgetPush_OneStepCrossingAggregatesMessage_AndDisabledPreferenceDoesNotBackfill()
{
using var enabled = await fixture.RegisterAsync("push_budget_one_step");
var enabledPreference = await enabled.PutAsJsonAsync(
"/api/push/preferences",
new { system = false, budget = true, operations = false });
enabledPreference.EnsureSuccessStatusCode();
var (enabledUserId, enabledLedgerId, enabledCategoryId) = await FinanceContext(enabled);
await PutBudget(enabled, enabledLedgerId, enabledCategoryId, 100);
await CreateExpense(enabled, enabledLedgerId, enabledCategoryId, 100, "budget-one-step");
Assert.Equal((2, 1), await BudgetCounts(enabledUserId));
using var disabled = await fixture.RegisterAsync("push_budget_disabled");
var (disabledUserId, disabledLedgerId, disabledCategoryId) = await FinanceContext(disabled);
await PutBudget(disabled, disabledLedgerId, disabledCategoryId, 100);
await CreateExpense(disabled, disabledLedgerId, disabledCategoryId, 100, "budget-disabled");
Assert.Equal((2, 0), await BudgetCounts(disabledUserId));
var disabledPreference = await disabled.PutAsJsonAsync(
"/api/push/preferences",
new { system = false, budget = true, operations = false });
disabledPreference.EnsureSuccessStatusCode();
await CreateExpense(disabled, disabledLedgerId, disabledCategoryId, 1, "budget-no-backfill");
Assert.Equal((2, 0), await BudgetCounts(disabledUserId));
}
[Fact]
public async Task AdminCampaign_ValidatesEstimatesSchedulesCancelsAndQueues()
{
using var user = await fixture.RegisterAsync("push_campaign_target");
await EnableSystemAsync(user);
var profile = await user.GetFromJsonAsync<JsonElement>("/api/users/me");
var userId = profile.GetProperty("userId").GetInt64();
await RegisterDeviceAsync(user, Guid.NewGuid().ToString(), "campaign-device-token");
using var admin = await fixture.AdminAsync();
var request = new
{
title = "系统维护通知",
body = "今晚 23:00 将进行短时维护",
category = "system",
action = "home",
flavor = "production",
provider = "xiaomi",
targetUserId = userId,
minVersionCode = 1,
maxVersionCode = 99999999,
ttlSeconds = 3600,
};
var estimate = await admin.PostAsJsonAsync("/api/admin/push/campaigns/estimate", request);
estimate.EnsureSuccessStatusCode();
Assert.Equal(
1,
(await estimate.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("devices").GetInt32());
var invalid = await admin.PostAsJsonAsync(
"/api/admin/push/campaigns",
new { request.title, request.body, category = "unknown", request.action, request.flavor });
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
var created = await admin.PostAsJsonAsync("/api/admin/push/campaigns", request);
created.EnsureSuccessStatusCode();
var campaign = await created.Content.ReadFromJsonAsync<JsonElement>();
var campaignId = campaign.GetProperty("id").GetInt64();
Assert.Equal(PushMessageStates.Draft, campaign.GetProperty("state").GetString());
var scheduled = await admin.PostAsJsonAsync(
$"/api/admin/push/campaigns/{campaignId}/send",
new { scheduledAt = DateTime.UtcNow.AddHours(1) });
scheduled.EnsureSuccessStatusCode();
Assert.Equal(
PushMessageStates.Scheduled,
(await scheduled.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("state").GetString());
var cancelled = await admin.PostAsync($"/api/admin/push/campaigns/{campaignId}/cancel", null);
cancelled.EnsureSuccessStatusCode();
var sendCancelled = await admin.PostAsJsonAsync(
$"/api/admin/push/campaigns/{campaignId}/send",
new { scheduledAt = (DateTime?)null });
Assert.Equal(HttpStatusCode.Conflict, sendCancelled.StatusCode);
var immediateDraft = await admin.PostAsJsonAsync(
"/api/admin/push/campaigns",
new { request.title, body = "立即发送", request.category, request.action, request.flavor, request.targetUserId });
immediateDraft.EnsureSuccessStatusCode();
var immediateId = (await immediateDraft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetInt64();
var queued = await admin.PostAsJsonAsync(
$"/api/admin/push/campaigns/{immediateId}/send",
new { scheduledAt = (DateTime?)null });
queued.EnsureSuccessStatusCode();
Assert.Equal(
PushMessageStates.Queued,
(await queued.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("state").GetString());
}
private static async Task EnableSystemAsync(HttpClient client)
{
var response = await client.PutAsJsonAsync(
"/api/push/preferences",
new { system = true, budget = false, operations = false });
response.EnsureSuccessStatusCode();
}
private static async Task<HttpResponseMessage> RegisterDeviceAsync(
HttpClient client,
string installationId,
string token)
{
var response = await client.PutAsJsonAsync(
$"/api/push/devices/{installationId}",
new
{
provider = "xiaomi",
token,
packageName = "com.nx.miaoji",
flavor = "production",
appVersion = "20260725-test",
versionCode = 20260725,
notificationsAllowed = true,
});
response.EnsureSuccessStatusCode();
return response;
}
private static async Task<HttpResponseMessage> DeleteWithUnbindToken(
HttpClient client,
string installationId,
string token)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/push/devices/{installationId}");
request.Headers.Add("X-Push-Unbind-Token", token);
return await client.SendAsync(request);
}
private async Task<bool> DeviceExists(long id)
{
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.PushDevices.AnyAsync(item => item.Id == id);
}
private static async Task<(long UserId, long LedgerId, long CategoryId)> FinanceContext(HttpClient client)
{
var profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var categories = await client.GetFromJsonAsync<JsonElement>("/api/categories?type=expense");
return (
profile.GetProperty("userId").GetInt64(),
ledgers[0].GetProperty("id").GetInt64(),
categories[0].GetProperty("id").GetInt64());
}
private static async Task PutBudget(HttpClient client, long ledgerId, long categoryId, decimal amount)
{
var response = await client.PutAsJsonAsync(
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
new { categoryId, amount, recurring = false });
response.EnsureSuccessStatusCode();
}
private static async Task CreateExpense(
HttpClient client,
long ledgerId,
long categoryId,
decimal amount,
string clientRequestId)
{
var response = await client.PostAsJsonAsync(
"/api/transactions",
new
{
ledgerId,
categoryId,
type = "expense",
amount,
occurredAt = "2026-07-25T12:00:00+08:00",
source = "manual",
clientRequestId,
});
response.EnsureSuccessStatusCode();
}
private async Task<(int Receipts, int Messages)> BudgetCounts(long userId)
{
await using var scope = fixture.Factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return (
await db.BudgetNotificationReceipts.CountAsync(item => item.UserId == userId),
await db.PushMessages.CountAsync(item => item.TargetUserId == userId && item.Source == "budget"));
}
}