307 lines
14 KiB
C#
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"));
|
|
}
|
|
}
|