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("/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("/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(); 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(); 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(); 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(); 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("/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()).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(); 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()).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()).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()).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 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 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 DeviceExists(long id) { await using var scope = fixture.Factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); 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("/api/users/me"); var ledgers = await client.GetFromJsonAsync("/api/ledgers"); var categories = await client.GetFromJsonAsync("/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(); return ( await db.BudgetNotificationReceipts.CountAsync(item => item.UserId == userId), await db.PushMessages.CountAsync(item => item.TargetUserId == userId && item.Source == "budget")); } }