Skip to content

Commit 88a430c

Browse files
authored
Fix broadcast "list all channels" to use /all-channels endpoint (#240)
The `.listAll` broadcast operation was targeting `/1/apps/{bundleID}/channels`, but Apple exposes "read all channels" on a distinct endpoint: `GET /1/apps/{bundleID}/all-channels`. Against real APNs the old path is not the list endpoint, so `readAllChannelIDs()` would not return the channel list. - Route `.listAll` to `/all-channels` in APNSBroadcastRequest. - Update APNSTestServer to serve `/all-channels` for listing and to require the `apns-channel-id` header on `GET /channels` (so it can no longer silently mirror the old, incorrect behaviour and mask a regression). - Add `testOperationPaths` pinning the path contract for every operation.
1 parent ff38db1 commit 88a430c

3 files changed

Lines changed: 28 additions & 5 deletions

File tree

Sources/APNSCore/Broadcast/APNSBroadcastRequest.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,12 @@ public struct APNSBroadcastRequest<Message: Encodable>: Sendable where Message:
4646
/// The path for this operation.
4747
public var path: String {
4848
switch self {
49-
case .create, .delete, .read, .listAll:
49+
case .create, .delete, .read:
5050
return "/channels"
51+
case .listAll:
52+
// Apple exposes "read all channels" on a distinct endpoint.
53+
// See: https://developer.apple.com/documentation/usernotifications/sending-channel-management-requests-to-apns
54+
return "/all-channels"
5155
}
5256
}
5357

Sources/APNSTestServer/APNSTestServer.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import struct Foundation.CharacterSet
2525
///
2626
/// This server supports both:
2727
/// - **Regular push notifications**: `POST /3/device/{token}`
28-
/// - **Broadcast channels**: `POST/GET/DELETE /channels[/{id}]`
28+
/// - **Broadcast channels**: `POST/GET/DELETE /1/apps/{bundleID}/channels` (+ `GET /1/apps/{bundleID}/all-channels`)
2929
///
3030
/// ## Usage
3131
///
@@ -141,11 +141,19 @@ public final class APNSTestServer: @unchecked Sendable {
141141
case (.POST, 4) where components[0] == "1" && components[1] == "apps" && components[3] == "channels":
142142
return handleCreateChannel(body: body)
143143

144+
case (.GET, 4) where components[0] == "1" && components[1] == "apps" && components[3] == "all-channels":
145+
return handleListChannels()
146+
144147
case (.GET, 4) where components[0] == "1" && components[1] == "apps" && components[3] == "channels":
145-
if let channelID = headers.first(name: "apns-channel-id") {
146-
return handleReadChannel(channelID: channelID)
148+
// Reading a single channel requires the channel ID header. Unlike the real
149+
// APNs API, `GET .../channels` without an ID is NOT the list endpoint
150+
// (that lives at `.../all-channels`), so reject it rather than silently listing.
151+
guard let channelID = headers.first(name: "apns-channel-id") else {
152+
var responseHeaders = HTTPHeaders()
153+
responseHeaders.add(name: "content-type", value: "application/json")
154+
return (.badRequest, responseHeaders, "{\"reason\":\"MissingChannelID\"}")
147155
}
148-
return handleListChannels()
156+
return handleReadChannel(channelID: channelID)
149157

150158
case (.DELETE, 4) where components[0] == "1" && components[1] == "apps" && components[3] == "channels":
151159
guard let channelID = headers.first(name: "apns-channel-id") else {

Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ final class APNSBroadcastClientTests: XCTestCase {
153153
XCTAssertEqual(channels.count, 0)
154154
}
155155

156+
// MARK: - Operation path contract
157+
158+
func testOperationPaths() {
159+
// Individual channel operations target `/channels`...
160+
XCTAssertEqual(APNSBroadcastRequest<EmptyPayload>(operation: .create).operation.path, "/channels")
161+
XCTAssertEqual(APNSBroadcastRequest<EmptyPayload>(operation: .read(channelID: "x")).operation.path, "/channels")
162+
XCTAssertEqual(APNSBroadcastRequest<EmptyPayload>(operation: .delete(channelID: "x")).operation.path, "/channels")
163+
// ...while "read all channels" lives on Apple's distinct `/all-channels` endpoint.
164+
XCTAssertEqual(APNSBroadcastRequest<EmptyPayload>(operation: .listAll).operation.path, "/all-channels")
165+
}
166+
156167
func testRequestID() async throws {
157168
let requestID = UUID()
158169
let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored)

0 commit comments

Comments
 (0)