From 4334e905fcd7d9ee124012ba1289af1a768ba877 Mon Sep 17 00:00:00 2001 From: lkoerber Date: Thu, 6 Aug 2026 02:24:54 +0200 Subject: [PATCH] Fix APNs dropping Data/Url; add Badge/Sound/Silent/ImageUrl; add maxConcurrency throttle --- README.md | 29 +++++-- .../NotifyHubEndpoints.cs | 21 ++++- src/NotifyHub/Channels/ApnsChannel.cs | 36 ++++++--- src/NotifyHub/Channels/FcmChannel.cs | 20 +++-- src/NotifyHub/Channels/WebPushChannel.cs | 2 + src/NotifyHub/Channels/WebhookChannel.cs | 12 ++- src/NotifyHub/NotificationMessage.cs | 33 +++++++- src/NotifyHub/NotificationSender.cs | 33 +++++++- tests/NotifyHub.Tests/ApnsChannelTests.cs | 81 +++++++++++++++++++ tests/NotifyHub.Tests/FcmChannelTests.cs | 43 ++++++++++ .../NotificationSenderTests.cs | 48 +++++++++++ 11 files changed, 328 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 56d4d0c..897c8f6 100644 --- a/README.md +++ b/README.md @@ -152,14 +152,24 @@ needs no configuration and is always active. | Type | Purpose | |---|---| | `Subscription` | One delivery target for exactly one channel (a browser's push endpoint, a device token, a webhook URL, or an email address). Created via `Subscription.WebPush(...)`, `.Apns(...)`, `.Fcm(...)`, `.Webhook(...)`, or `.Email(...)`. | -| `NotificationMessage` | Channel-independent content: `Title`, `Body`, optional `Url` and `Data` dictionary. | -| `NotificationSender` | The single entry point. `SendAsync(message, subscriptions, channels?)` fans out to every subscription's channel in parallel and returns one `ChannelSendResult` per subscription. Which users/subscriptions are targeted is entirely up to what you pass in; the optional `channels` allow-list restricts delivery to specific channel types (e.g. WebPush only) without having to filter the subscription list yourself. | +| `NotificationMessage` | Channel-independent content: `Title`, `Body`, optional `Url`, `Data` dictionary, `Badge` (APNs badge count), `Sound` (APNs custom sound), `Silent` (background/data-only push - APNs/FCM/WebPush), `ImageUrl` (FCM/WebPush/Webhook). Every field beyond `Title`/`Body` is optional and only used by the channels that understand it. | +| `NotificationSender` | The single entry point. `SendAsync(message, subscriptions, channels?, maxConcurrency?)` fans out to every subscription's channel in parallel and returns one `ChannelSendResult` per subscription. Which users/subscriptions are targeted is entirely up to what you pass in; the optional `channels` allow-list restricts delivery to specific channel types (e.g. WebPush only) without having to filter the subscription list yourself; the optional `maxConcurrency` caps how many sends run at once (useful for very large broadcasts - see below). | | `ChannelSendResult` / `SendOutcome` | Per-subscription outcome: `Delivered`, `Expired`, `Failed`, or `Skipped`. See [Handling send results](#handling-send-results). | NotifyHub never persists subscriptions itself - the host app owns that list completely and passes the currently relevant subset into `SendAsync` on every call. The only thing NotifyHub persists on its own is the Web Push VAPID key pair (see [Custom storage](#custom-storage)). +**Large broadcasts:** `SendAsync` fans out with unbounded parallelism by default (unchanged, +zero-config behavior) - fine for small/medium subscriber counts. Sending to tens of thousands of +subscriptions at once can exhaust the local HTTP connection pool and trip provider-side rate +limits (APNs/FCM throttle aggressively), which would show up as spurious `SendOutcome.Failed` +results. Pass `maxConcurrency` to cap how many sends are in flight simultaneously: + +```csharp +await sender.SendAsync(message, allSubscriptions, maxConcurrency: 200); +``` + ## Channel reference ### Web Push (VAPID) @@ -196,6 +206,11 @@ Subscription.Apns(deviceToken); Without `ApnsOptions`, the channel is a silent no-op. Expired/uninstalled tokens are reported as `SendOutcome.Expired` (HTTP 410, `BadDeviceToken`, `DeviceTokenNotForTopic`). +`NotificationMessage.Data`/`Url` are sent as top-level keys alongside `"aps"` (Apple's convention +for custom payload data), `Badge` maps to `aps.badge`, `Sound` overrides the default notification +sound, and `Silent: true` sends a background push (`aps.content-available: 1`, no `alert`/`sound`, +`apns-push-type: background`, `apns-priority: 5`) for silent data sync instead of a visible alert. + ### Firebase Cloud Messaging (FCM) Android (and any platform reachable through Firebase) push via the FCM HTTP v1 API, authenticated @@ -213,6 +228,10 @@ Subscription.Fcm(deviceToken); Without `FcmOptions`, the channel is a silent no-op. `UNREGISTERED`/`NOT_FOUND` responses are reported as `SendOutcome.Expired`. +`NotificationMessage.ImageUrl` maps to `notification.image`. `Silent: true` sends a data-only +message (the `notification` key is omitted entirely - only `Data` is delivered), for background +sync without a visible notification. `Badge`/`Sound` are APNs-specific and not applicable here. + ### Webhook Always active, no configuration required. POSTs the notification to any URL - useful for Slack, @@ -222,8 +241,8 @@ Discord, Home Assistant, n8n, or your own service. Subscription.Webhook("https://your-service.example.com/hooks/notify"); ``` -By default the body is NotifyHub's own generic shape (`{ title, body, url, data }`), which Home -Assistant/n8n/your own endpoints can read directly. **Slack and Discord expect their own shape and +By default the body is NotifyHub's own generic shape (`{ title, body, url, data, image, badge, sound, silent }`), +which Home Assistant/n8n/your own endpoints can read directly. **Slack and Discord expect their own shape and reject the generic one** - pass `format` to match the target: ```csharp @@ -325,7 +344,7 @@ app.MapNotifyHubEndpoints(); // mounted at /notifyhub by default | `POST` | `/notifyhub/subscriptions` | `{ userId, channel, ... }` | Registers or updates a subscription for a user. `channel` is the numeric `NotificationChannel` value (0=WebPush, 1=Apns, 2=Fcm, 3=Webhook, 4=Email); the remaining fields depend on the channel (`endpoint`/`p256dh`/`auth`, `deviceToken`, `url`, or `emailAddress`). | | `DELETE` | `/notifyhub/subscriptions/{id}` | - | Removes a subscription by its server-assigned ID. | | `GET` | `/notifyhub/subscriptions?userId=...` | - | Lists a user's subscriptions (ID, channel, creation time). | -| `POST` | `/notifyhub/notifications/send` | `{ userId?, userIds?, broadcast, title, body, url?, data?, channels? }` | Sends to one user's subscriptions, a specific list of users' (`userIds`), or to everyone if `broadcast` is true. Optional `channels` (e.g. `[0]` for WebPush only) restricts delivery to just those channel types - omit it to send across every channel the target(s) are subscribed to, as before. Automatically deletes subscriptions that come back `Expired`. | +| `POST` | `/notifyhub/notifications/send` | `{ userId?, userIds?, broadcast, title, body, url?, data?, channels?, badge?, sound?, silent?, imageUrl?, maxConcurrency? }` | Sends to one user's subscriptions, a specific list of users' (`userIds`), or to everyone if `broadcast` is true. Optional `channels` (e.g. `[0]` for WebPush only) restricts delivery to just those channel types - omit it to send across every channel the target(s) are subscribed to, as before. `badge`/`sound`/`silent`/`imageUrl` map to the matching `NotificationMessage` fields (see [Core concepts](#core-concepts)); `maxConcurrency` caps parallel sends for large broadcasts. Automatically deletes subscriptions that come back `Expired`. | Change the route prefix with `app.MapNotifyHubEndpoints("/my-prefix")`. diff --git a/src/NotifyHub.AspNetCore/NotifyHubEndpoints.cs b/src/NotifyHub.AspNetCore/NotifyHubEndpoints.cs index 7e6b181..4bd4645 100644 --- a/src/NotifyHub.AspNetCore/NotifyHubEndpoints.cs +++ b/src/NotifyHub.AspNetCore/NotifyHubEndpoints.cs @@ -25,7 +25,12 @@ public sealed record SendRequest( string Body, string? Url = null, Dictionary? Data = null, - NotificationChannel[]? Channels = null); + NotificationChannel[]? Channels = null, + int? Badge = null, + string? Sound = null, + bool Silent = false, + string? ImageUrl = null, + int? MaxConcurrency = null); public sealed record SendResultDto(string? SubscriptionId, NotificationChannel Channel, string Outcome, string? Error); @@ -112,8 +117,18 @@ public static RouteGroupBuilder MapNotifyHubEndpoints(this IEndpointRouteBuilder if (targets.Count == 0) return Results.Ok(Array.Empty()); - var message = new NotificationMessage { Title = req.Title, Body = req.Body, Url = req.Url, Data = req.Data }; - var results = await sender.SendAsync(message, targets.Select(t => t.Subscription), req.Channels); + var message = new NotificationMessage + { + Title = req.Title, + Body = req.Body, + Url = req.Url, + Data = req.Data, + Badge = req.Badge, + Sound = req.Sound, + Silent = req.Silent, + ImageUrl = req.ImageUrl, + }; + var results = await sender.SendAsync(message, targets.Select(t => t.Subscription), req.Channels, req.MaxConcurrency); // Automatically clean up expired subscriptions (pattern: HTTP 410/BadDeviceToken/UNREGISTERED). foreach (var (target, result) in targets.Zip(results)) diff --git a/src/NotifyHub/Channels/ApnsChannel.cs b/src/NotifyHub/Channels/ApnsChannel.cs index dc9feaa..61eed0c 100644 --- a/src/NotifyHub/Channels/ApnsChannel.cs +++ b/src/NotifyHub/Channels/ApnsChannel.cs @@ -48,14 +48,31 @@ public async Task SendAsync(Subscription subscription, Notifi var options = _options!; var endpoint = options.Endpoint ?? (options.UseSandbox ? SandboxEndpoint : ProductionEndpoint); - var apsJson = JsonSerializer.Serialize(new + var aps = new Dictionary(); + if (message.Silent) { - aps = new - { - alert = new { title = message.Title, body = message.Body }, - sound = "default", - }, - }); + // Background/silent push per Apple's spec: content-available only, no alert/sound. + aps["content-available"] = 1; + } + else + { + aps["alert"] = new { title = message.Title, body = message.Body }; + aps["sound"] = message.Sound ?? "default"; + } + if (message.Badge is { } badge) + aps["badge"] = badge; + + var payload = new Dictionary{ ["aps"] = aps }; + if (message.Data is not null) + { + // Apple convention: custom data lives as top-level keys alongside "aps", not nested. + foreach (var (key, value) in message.Data) + payload[key] = value; + } + if (message.Url is not null) + payload["url"] = message.Url; + + var apsJson = JsonSerializer.Serialize(payload); try { @@ -67,8 +84,9 @@ public async Task SendAsync(Subscription subscription, Notifi }; request.Headers.TryAddWithoutValidation("authorization", $"bearer {GetJwt(options)}"); request.Headers.TryAddWithoutValidation("apns-topic", options.BundleId); - request.Headers.TryAddWithoutValidation("apns-push-type", "alert"); - request.Headers.TryAddWithoutValidation("apns-priority", "10"); + request.Headers.TryAddWithoutValidation("apns-push-type", message.Silent ? "background" : "alert"); + // Apple requires priority 5 for background/content-available pushes, 10 (immediate) for alerts. + request.Headers.TryAddWithoutValidation("apns-priority", message.Silent ? "5" : "10"); using var response = await _http.SendAsync(request, ct); if (response.IsSuccessStatusCode) diff --git a/src/NotifyHub/Channels/FcmChannel.cs b/src/NotifyHub/Channels/FcmChannel.cs index 1669bf8..6af3ece 100644 --- a/src/NotifyHub/Channels/FcmChannel.cs +++ b/src/NotifyHub/Channels/FcmChannel.cs @@ -49,15 +49,21 @@ public async Task SendAsync(Subscription subscription, Notifi throw new ArgumentException("FCM subscription requires DeviceToken.", nameof(subscription)); var options = _options!; - var body = new + // A silent/data-only message omits "notification" entirely per FCM's convention - the + // app receives only "data" and decides itself whether/how to surface anything. + object? notification = message.Silent + ? null + : new { title = message.Title, body = message.Body, image = message.ImageUrl }; + + var messageFields = new Dictionary { - message = new - { - token = subscription.DeviceToken, - notification = new { title = message.Title, body = message.Body }, - data = message.Data, - }, + ["token"] = subscription.DeviceToken, + ["data"] = message.Data, }; + if (notification is not null) + messageFields["notification"] = notification; + + var body = new { message = messageFields }; try { diff --git a/src/NotifyHub/Channels/WebPushChannel.cs b/src/NotifyHub/Channels/WebPushChannel.cs index ba43ce7..125d743 100644 --- a/src/NotifyHub/Channels/WebPushChannel.cs +++ b/src/NotifyHub/Channels/WebPushChannel.cs @@ -45,6 +45,8 @@ public async Task SendAsync(Subscription subscription, Notifi body = message.Body, url = message.Url, data = message.Data, + image = message.ImageUrl, + silent = message.Silent, }); var body = WebPushCrypto.EncryptPayload(payload, subscription.P256dh, subscription.Auth); diff --git a/src/NotifyHub/Channels/WebhookChannel.cs b/src/NotifyHub/Channels/WebhookChannel.cs index 924a1fe..d3ad024 100644 --- a/src/NotifyHub/Channels/WebhookChannel.cs +++ b/src/NotifyHub/Channels/WebhookChannel.cs @@ -84,7 +84,17 @@ public async Task SendAsync(Subscription subscription, Notifi { WebhookPayloadFormat.Slack => JsonSerializer.Serialize(new { text = FormatText(message, "*") }), WebhookPayloadFormat.Discord => JsonSerializer.Serialize(new { content = FormatText(message, "**") }), - _ => JsonSerializer.Serialize(new { title = message.Title, body = message.Body, url = message.Url, data = message.Data }), + _ => JsonSerializer.Serialize(new + { + title = message.Title, + body = message.Body, + url = message.Url, + data = message.Data, + image = message.ImageUrl, + badge = message.Badge, + sound = message.Sound, + silent = message.Silent, + }), }; /// Combines title/body/url into one message string with the given emphasis markup diff --git a/src/NotifyHub/NotificationMessage.cs b/src/NotifyHub/NotificationMessage.cs index 66be9ce..40b96d9 100644 --- a/src/NotifyHub/NotificationMessage.cs +++ b/src/NotifyHub/NotificationMessage.cs @@ -1,6 +1,9 @@ namespace NotifyHub; -/// Channel-independent content of a notification. +/// Channel-independent content of a notification. Every field beyond +/// / is optional - each channel uses only the fields it +/// understands and ignores the rest, so the same message can be sent across every channel type +/// without channel-specific branching in the caller. public sealed record NotificationMessage { public required string Title { get; init; } @@ -9,6 +12,32 @@ public sealed record NotificationMessage /// Optional target URL to open when the notification is tapped. public string? Url { get; init; } - /// Additional, channel-specific payload data (e.g. for deep links). + /// Additional, channel-specific payload data (e.g. for deep links). Delivered as + /// top-level custom keys alongside "aps" for APNs, as the "data" field for FCM, + /// and as "data" in the WebPush/Webhook (generic format) JSON payload. public IReadOnlyDictionary? Data { get; init; } + + /// App icon badge count. Maps to APNs aps.badge. Not applicable to + /// WebPush/FCM/Webhook/Email - ignored there. Leave unset to not touch the app's existing + /// badge count (Apple's default behavior when this field is omitted). + public int? Badge { get; init; } + + /// Custom notification sound (APNs aps.sound). Defaults to the platform's + /// standard sound when left unset. Not applicable to WebPush/FCM/Webhook/Email. + public string? Sound { get; init; } + + /// When true, sends a silent/background notification instead of a visible one: + /// APNs content-available: 1 (no alert/sound), FCM a data-only message + /// (no notification key - only), WebPush a + /// Notification(..., { silent: true }) hint for the host's own service worker. + /// Useful for background sync. Default false (a normal, visible notification). Not + /// applicable to Webhook/Email. + public bool Silent { get; init; } + + /// Optional image/icon URL. Passed through as FCM's notification.image and + /// included in the WebPush/Webhook (generic format) JSON payload for the host's own service + /// worker/receiver to use. Not applicable to APNs (rich image attachments there require a + /// Notification Service Extension on the app side - out of scope for a server-side push) or + /// Email. + public string? ImageUrl { get; init; } } diff --git a/src/NotifyHub/NotificationSender.cs b/src/NotifyHub/NotificationSender.cs index 0a63b24..eb74567 100644 --- a/src/NotifyHub/NotificationSender.cs +++ b/src/NotifyHub/NotificationSender.cs @@ -29,15 +29,42 @@ public NotificationSender(IEnumerable channels) /// without being sent - a convenience for "send to these /// subscriptions, but only via WebPush" without having to filter the list yourself. Omit it /// (the default) to send across every channel, unchanged from before this parameter - /// existed. + /// existed. + /// + /// is an optional cap on how many sends run at once. Left + /// unset (the default), every subscription is sent in full parallel via + /// , unchanged from before this + /// parameter existed - fine for small/medium subscriber counts. For a large broadcast (e.g. + /// tens of thousands of subscriptions), firing every send at once can exhaust the local + /// connection pool and trip provider-side rate limits (APNs/FCM throttle aggressively) - + /// set a cap to bound how many HTTP calls are in flight simultaneously. public async Task> SendAsync( NotificationMessage message, IEnumerable subscriptions, IReadOnlyCollection? channels = null, + int? maxConcurrency = null, CancellationToken ct = default) { - var tasks = subscriptions.Select(subscription => SendOneAsync(subscription, message, channels, ct)); - return await Task.WhenAll(tasks); + if (maxConcurrency is null) + { + var tasks = subscriptions.Select(subscription => SendOneAsync(subscription, message, channels, ct)); + return await Task.WhenAll(tasks); + } + + using var throttle = new SemaphoreSlim(maxConcurrency.Value); + var throttledTasks = subscriptions.Select(async subscription => + { + await throttle.WaitAsync(ct); + try + { + return await SendOneAsync(subscription, message, channels, ct); + } + finally + { + throttle.Release(); + } + }); + return await Task.WhenAll(throttledTasks); } private async Task SendOneAsync( diff --git a/tests/NotifyHub.Tests/ApnsChannelTests.cs b/tests/NotifyHub.Tests/ApnsChannelTests.cs index e564c4e..ed64252 100644 --- a/tests/NotifyHub.Tests/ApnsChannelTests.cs +++ b/tests/NotifyHub.Tests/ApnsChannelTests.cs @@ -124,4 +124,85 @@ public async Task GetJwt_IsCached_AcrossCalls() } finally { File.Delete(keyPath); } } + + [Fact] + public async Task SendAsync_IncludesDataAndUrl_AsTopLevelKeys() + { + var keyPath = CreateTempP8Key(); + try + { + string? capturedBody = null; + var handler = new FakeHttpMessageHandler().Enqueue(req => + { + capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var channel = new ApnsChannel(CreateOptions(keyPath), new HttpClient(handler)); + var message = new NotificationMessage + { + Title = "T", + Body = "B", + Url = "https://example.com/deep-link", + Data = new Dictionary { ["entityId"] = "42" }, + }; + + await channel.SendAsync(Subscription.Apns("devicetoken"), message); + + // Regression test: Data/Url used to be silently dropped for APNs - Apple's convention + // is custom keys as top-level siblings of "aps", not nested inside it. + Assert.Contains("\"entityId\":\"42\"", capturedBody); + Assert.Contains("\"url\":\"https://example.com/deep-link\"", capturedBody); + } + finally { File.Delete(keyPath); } + } + + [Fact] + public async Task SendAsync_IncludesBadgeAndCustomSound_WhenSet() + { + var keyPath = CreateTempP8Key(); + try + { + string? capturedBody = null; + var handler = new FakeHttpMessageHandler().Enqueue(req => + { + capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var channel = new ApnsChannel(CreateOptions(keyPath), new HttpClient(handler)); + var message = new NotificationMessage { Title = "T", Body = "B", Badge = 7, Sound = "chime.caf" }; + + await channel.SendAsync(Subscription.Apns("devicetoken"), message); + + Assert.Contains("\"badge\":7", capturedBody); + Assert.Contains("\"sound\":\"chime.caf\"", capturedBody); + } + finally { File.Delete(keyPath); } + } + + [Fact] + public async Task SendAsync_SendsBackgroundPush_WhenSilent() + { + var keyPath = CreateTempP8Key(); + try + { + string? capturedBody = null; + HttpRequestMessage? capturedRequest = null; + var handler = new FakeHttpMessageHandler().Enqueue(req => + { + capturedRequest = req; + capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var channel = new ApnsChannel(CreateOptions(keyPath), new HttpClient(handler)); + + await channel.SendAsync(Subscription.Apns("devicetoken"), new NotificationMessage { Title = "T", Body = "B", Silent = true }); + + Assert.Contains("\"content-available\":1", capturedBody); + Assert.DoesNotContain("\"alert\"", capturedBody); + Assert.DoesNotContain("\"sound\"", capturedBody); + Assert.Equal("background", capturedRequest!.Headers.GetValues("apns-push-type").Single()); + Assert.Equal("5", capturedRequest.Headers.GetValues("apns-priority").Single()); + } + finally { File.Delete(keyPath); } + } } diff --git a/tests/NotifyHub.Tests/FcmChannelTests.cs b/tests/NotifyHub.Tests/FcmChannelTests.cs index 8f52b2f..6cbe9df 100644 --- a/tests/NotifyHub.Tests/FcmChannelTests.cs +++ b/tests/NotifyHub.Tests/FcmChannelTests.cs @@ -98,4 +98,47 @@ public async Task AccessToken_IsCached_AcrossCalls() // Only ONE token request overall (1x token + 2x send = 3 requests), since the token is cached. Assert.Equal(3, handler.Requests.Count); } + + [Fact] + public async Task SendAsync_IncludesImage_WhenSet() + { + var handler = EnqueueTokenResponse(new FakeHttpMessageHandler()); + string? capturedBody = null; + handler.Enqueue(req => + { + capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var channel = new FcmChannel(CreateOptions(), new HttpClient(handler)); + var message = new NotificationMessage { Title = "T", Body = "B", ImageUrl = "https://example.com/pic.png" }; + + await channel.SendAsync(Subscription.Fcm("devicetoken"), message); + + Assert.Contains("\"image\":\"https://example.com/pic.png\"", capturedBody); + } + + [Fact] + public async Task SendAsync_OmitsNotification_WhenSilent() + { + var handler = EnqueueTokenResponse(new FakeHttpMessageHandler()); + string? capturedBody = null; + handler.Enqueue(req => + { + capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var channel = new FcmChannel(CreateOptions(), new HttpClient(handler)); + var message = new NotificationMessage + { + Title = "T", + Body = "B", + Silent = true, + Data = new Dictionary { ["syncToken"] = "abc" }, + }; + + await channel.SendAsync(Subscription.Fcm("devicetoken"), message); + + Assert.DoesNotContain("\"notification\"", capturedBody); + Assert.Contains("\"syncToken\":\"abc\"", capturedBody); + } } diff --git a/tests/NotifyHub.Tests/NotificationSenderTests.cs b/tests/NotifyHub.Tests/NotificationSenderTests.cs index 2acb5e9..2fb5297 100644 --- a/tests/NotifyHub.Tests/NotificationSenderTests.cs +++ b/tests/NotifyHub.Tests/NotificationSenderTests.cs @@ -137,6 +137,54 @@ public async Task SendAsync_EmptyChannelFilter_SkipsEverything() Assert.Equal(SendOutcome.Skipped, results[0].Outcome); } + [Fact] + public async Task SendAsync_LimitsConcurrency_WhenMaxConcurrencySet() + { + var tracker = new ConcurrencyTrackingChannel(NotificationChannel.WebPush, TimeSpan.FromMilliseconds(50)); + var sender = new NotificationSender([tracker]); + var subscriptions = Enumerable.Range(0, 10).Select(i => Subscription.WebPush($"endpoint{i}", "p256dh", "auth")).ToList(); + + var results = await sender.SendAsync(Message, subscriptions, maxConcurrency: 2); + + Assert.Equal(10, results.Count); + Assert.True(tracker.MaxObservedConcurrency <= 2, $"Expected max concurrency <= 2 but was {tracker.MaxObservedConcurrency}."); + } + + [Fact] + public async Task SendAsync_AllowsFullConcurrency_WhenMaxConcurrencyNotSet() + { + var tracker = new ConcurrencyTrackingChannel(NotificationChannel.WebPush, TimeSpan.FromMilliseconds(50)); + var sender = new NotificationSender([tracker]); + var subscriptions = Enumerable.Range(0, 10).Select(i => Subscription.WebPush($"endpoint{i}", "p256dh", "auth")).ToList(); + + await sender.SendAsync(Message, subscriptions); + + Assert.Equal(10, tracker.MaxObservedConcurrency); + } + + private sealed class ConcurrencyTrackingChannel(NotificationChannel channel, TimeSpan delay) : Abstractions.INotificationChannel + { + private int _current; + private readonly Lock _lock = new(); + + public int MaxObservedConcurrency { get; private set; } + public NotificationChannel Channel { get; } = channel; + public bool Enabled => true; + + public async Task SendAsync(Subscription subscription, NotificationMessage message, CancellationToken ct = default) + { + var current = Interlocked.Increment(ref _current); + lock (_lock) + { + if (current > MaxObservedConcurrency) + MaxObservedConcurrency = current; + } + await Task.Delay(delay, ct); + Interlocked.Decrement(ref _current); + return new ChannelSendResult(subscription, SendOutcome.Delivered); + } + } + private sealed class ThrowingChannel(NotificationChannel channel) : Abstractions.INotificationChannel { public NotificationChannel Channel { get; } = channel;