Add domestic vendor push infrastructure
This commit is contained in:
@@ -41,9 +41,13 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
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");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Admin__Key",
|
||||
"test-only-admin-key-at-least-24-characters");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Push__TokenEncryptionKey",
|
||||
Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()));
|
||||
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", "1000");
|
||||
|
||||
Factory = new WebApplicationFactory<Program>().WithWebHostBuilder(
|
||||
builder =>
|
||||
@@ -65,8 +69,10 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
Factory?.Dispose();
|
||||
await _database.DisposeAsync();
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
|
||||
Environment.SetEnvironmentVariable("Jwt__Secret", null);
|
||||
Environment.SetEnvironmentVariable("Admin__Key", null);
|
||||
Environment.SetEnvironmentVariable("Jwt__Secret", null);
|
||||
Environment.SetEnvironmentVariable("Admin__Key", null);
|
||||
Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null);
|
||||
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null);
|
||||
}
|
||||
|
||||
public async Task<HttpClient> RegisterAsync(string username)
|
||||
@@ -395,7 +401,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
[Fact]
|
||||
public async Task RecognitionBatch_PreservesThreeConsecutiveTransfers_AndIsIdempotent()
|
||||
{
|
||||
using var client = await fixture.RegisterAsync("recognition_batch_three_transfers");
|
||||
using var client = await fixture.RegisterAsync("recognition_batch_transfers");
|
||||
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
|
||||
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
|
||||
var categories = await client.GetFromJsonAsync<JsonElement>(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MiaoJiZhang.Api.Tests;
|
||||
|
||||
public sealed class PushProviderTests
|
||||
{
|
||||
private static readonly PushEnvelope Message = new(
|
||||
"message-1",
|
||||
"测试标题",
|
||||
"测试正文",
|
||||
"system",
|
||||
"home",
|
||||
null,
|
||||
3600);
|
||||
|
||||
[Fact]
|
||||
public async Task Xiaomi_Http200BusinessFailure_IsNotAccepted()
|
||||
{
|
||||
var factory = new StubHttpClientFactory(_ => Json(
|
||||
"""{"result":"error","code":70000003,"description":"invalid registration_id"}"""));
|
||||
var provider = Provider("xiaomi", factory, new Dictionary<string, string?>
|
||||
{
|
||||
["Push:Providers:xiaomi:production:Enabled"] = "true",
|
||||
["Push:Providers:xiaomi:production:AppSecret"] = "server-secret",
|
||||
});
|
||||
|
||||
var result = await provider.SendAsync(
|
||||
"production",
|
||||
"com.nx.miaoji",
|
||||
"invalid-token",
|
||||
Message,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.True(result.InvalidToken);
|
||||
Assert.Equal("provider_error", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Xiaomi_Http200Success_IsAccepted()
|
||||
{
|
||||
var factory = new StubHttpClientFactory(_ => Json(
|
||||
"""{"result":"ok","code":0,"data":{"id":"xiaomi-message"}}"""));
|
||||
var provider = Provider("xiaomi", factory, new Dictionary<string, string?>
|
||||
{
|
||||
["Push:Providers:xiaomi:production:Enabled"] = "true",
|
||||
["Push:Providers:xiaomi:production:AppSecret"] = "server-secret",
|
||||
});
|
||||
|
||||
var result = await provider.SendAsync(
|
||||
"production",
|
||||
"com.nx.miaoji",
|
||||
"valid-token",
|
||||
Message,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.False(result.InvalidToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Huawei_AccessTokens_AreCachedPerFlavor()
|
||||
{
|
||||
var authCalls = new Dictionary<string, int>();
|
||||
var sendTokens = new Dictionary<string, List<string>>();
|
||||
var factory = new StubHttpClientFactory(request =>
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
var flavor = path.Contains("production", StringComparison.Ordinal)
|
||||
? "production"
|
||||
: "internal";
|
||||
if (path.EndsWith("/auth", StringComparison.Ordinal))
|
||||
{
|
||||
authCalls[flavor] = authCalls.GetValueOrDefault(flavor) + 1;
|
||||
return Json($$"""{"access_token":"{{flavor}}-token","expires_in":3600}""");
|
||||
}
|
||||
|
||||
sendTokens.TryAdd(flavor, []);
|
||||
sendTokens[flavor].Add(request.Headers.Authorization?.Parameter ?? "");
|
||||
return Json("""{"code":"80000000","requestId":"huawei-message"}""");
|
||||
});
|
||||
var provider = Provider("huawei", factory, new Dictionary<string, string?>
|
||||
{
|
||||
["Push:Providers:huawei:production:Enabled"] = "true",
|
||||
["Push:Providers:huawei:production:AppId"] = "production-app",
|
||||
["Push:Providers:huawei:production:AppSecret"] = "production-secret",
|
||||
["Push:Providers:huawei:production:AuthUrl"] = "https://push.test/production/auth",
|
||||
["Push:Providers:huawei:production:SendUrl"] = "https://push.test/production/send",
|
||||
["Push:Providers:huawei:internal:Enabled"] = "true",
|
||||
["Push:Providers:huawei:internal:AppId"] = "internal-app",
|
||||
["Push:Providers:huawei:internal:AppSecret"] = "internal-secret",
|
||||
["Push:Providers:huawei:internal:AuthUrl"] = "https://push.test/internal/auth",
|
||||
["Push:Providers:huawei:internal:SendUrl"] = "https://push.test/internal/send",
|
||||
});
|
||||
|
||||
Assert.True((await provider.SendAsync(
|
||||
"production", "com.nx.miaoji", "token-1", Message, CancellationToken.None)).Accepted);
|
||||
Assert.True((await provider.SendAsync(
|
||||
"internal", "com.nx.miaoji.internal", "token-2", Message, CancellationToken.None)).Accepted);
|
||||
Assert.True((await provider.SendAsync(
|
||||
"production", "com.nx.miaoji", "token-3", Message, CancellationToken.None)).Accepted);
|
||||
|
||||
Assert.Equal(1, authCalls["production"]);
|
||||
Assert.Equal(1, authCalls["internal"]);
|
||||
Assert.Equal(["production-token", "production-token"], sendTokens["production"]);
|
||||
Assert.Equal(["internal-token"], sendTokens["internal"]);
|
||||
}
|
||||
|
||||
private static OfficialPushProvider Provider(
|
||||
string name,
|
||||
IHttpClientFactory factory,
|
||||
Dictionary<string, string?> values)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
return new OfficialPushProvider(
|
||||
name,
|
||||
configuration,
|
||||
factory,
|
||||
NullLogger<OfficialPushProvider>.Instance);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body),
|
||||
};
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient client;
|
||||
|
||||
public StubHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> response)
|
||||
{
|
||||
client = new HttpClient(new StubHandler(response));
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => client;
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(response(request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user