Add domestic vendor push infrastructure
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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)
|
||||
{
|
||||
private const string AdminKey = "test-only-admin-key-at-least-24-characters";
|
||||
|
||||
[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 = fixture.Factory.CreateClient();
|
||||
admin.DefaultRequestHeaders.Add("X-Admin-Key", AdminKey);
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user