Skip to content

Commit 356534b

Browse files
authored
Add NIO APNSClient send coverage against APNSTestServer (#242)
The NIO-based `APNSClient` send path had effectively no functional tests — only `testShutdown` and a compile-only actor check — even though `APNSTestServer` already exists and is used by the broadcast client. - Add `APNSClientSendTests` covering: successful send, full request-header propagation (push-type, topic, priority, expiration, collapse-id), the bad-device-token and missing-topic error branches, and the `410 Unregistered` path including `APNSError.timestamp` decoding. - Teach `APNSTestServer` to simulate an unregistered token: a valid-hex token equal to `unregisteredDeviceToken` returns `410 Unregistered` with a `timestamp`, so the timestamp decoding path is exercisable.
1 parent ca89e26 commit 356534b

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

Sources/APNSTestServer/APNSTestServer.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ public final class APNSTestServer: @unchecked Sendable {
8686
}
8787
}
8888

89+
/// A valid-hex device token that the server treats as unregistered, responding with `410 Unregistered`.
90+
public static let unregisteredDeviceToken = String(repeating: "f", count: 64)
91+
92+
/// The `timestamp` (milliseconds since epoch) returned alongside a simulated `410 Unregistered` response.
93+
public static let unregisteredTimestampMilliseconds = 1_454_096_879_000
94+
8995
public init() {
9096
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
9197
}
@@ -276,6 +282,15 @@ public final class APNSTestServer: @unchecked Sendable {
276282
return (.badRequest, responseHeaders, "{\"reason\":\"BadDeviceToken\"}")
277283
}
278284

285+
// Simulate an unregistered token: a valid-hex token equal to `Self.unregisteredDeviceToken`
286+
// responds with `410 Unregistered` and a `timestamp`, mirroring Apple's behaviour so the
287+
// `APNSError.timestamp` decoding path can be exercised.
288+
if deviceToken == Self.unregisteredDeviceToken {
289+
var responseHeaders = HTTPHeaders()
290+
responseHeaders.add(name: "content-type", value: "application/json")
291+
return (.gone, responseHeaders, "{\"reason\":\"Unregistered\",\"timestamp\":\(Self.unregisteredTimestampMilliseconds)}")
292+
}
293+
279294
// Validate required topic header
280295
guard headers.contains(name: "apns-topic") else {
281296
var responseHeaders = HTTPHeaders()
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the APNSwift open source project
4+
//
5+
// Copyright (c) 2024 the APNSwift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of APNSwift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import APNSCore
16+
import APNS
17+
import APNSTestServer
18+
import Crypto
19+
import NIOPosix
20+
import XCTest
21+
22+
/// Exercises the NIO-based ``APNSClient`` send path end-to-end against ``APNSTestServer``.
23+
final class APNSClientSendTests: XCTestCase {
24+
var server: APNSTestServer!
25+
var client: APNSClient<JSONDecoder, JSONEncoder>!
26+
27+
override func setUp() async throws {
28+
try await super.setUp()
29+
30+
server = APNSTestServer()
31+
try await server.start(port: 0)
32+
33+
client = APNSClient(
34+
configuration: .init(
35+
authenticationMethod: .jwt(
36+
privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey),
37+
keyIdentifier: "MY_KEY_ID",
38+
teamIdentifier: "MY_TEAM_ID"
39+
),
40+
environment: .custom(url: "http://127.0.0.1", port: server.port)
41+
),
42+
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton),
43+
responseDecoder: JSONDecoder(),
44+
requestEncoder: JSONEncoder()
45+
)
46+
}
47+
48+
override func tearDown() async throws {
49+
try await client?.shutdown()
50+
try await server?.shutdown()
51+
client = nil
52+
server = nil
53+
try await super.tearDown()
54+
}
55+
56+
func testSendAlert_success() async throws {
57+
let response = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
58+
59+
XCTAssertNotNil(response.apnsID)
60+
XCTAssertEqual(server.getSentNotifications().count, 1)
61+
}
62+
63+
func testSendAlert_propagatesAllHeaders() async throws {
64+
var alert = APNSAlertNotification(
65+
alert: .init(title: .raw("title")),
66+
expiration: .immediately,
67+
priority: .immediately,
68+
topic: "com.example.app",
69+
payload: EmptyPayload()
70+
)
71+
alert.collapseID = "collapse-123"
72+
_ = try await client.sendAlertNotification(alert, deviceToken: Self.validDeviceToken)
73+
74+
let sent = try XCTUnwrap(server.getSentNotifications().first)
75+
XCTAssertEqual(sent.deviceToken, Self.validDeviceToken)
76+
XCTAssertEqual(sent.pushType, "alert")
77+
XCTAssertEqual(sent.topic, "com.example.app")
78+
XCTAssertEqual(sent.priority, "10")
79+
XCTAssertEqual(sent.expiration, "0")
80+
XCTAssertEqual(sent.collapseID, "collapse-123")
81+
}
82+
83+
func testSendAlert_badDeviceTokenThrowsTypedError() async throws {
84+
do {
85+
_ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: "not-valid")
86+
XCTFail("Expected an APNSError to be thrown")
87+
} catch let error as APNSError {
88+
XCTAssertEqual(error.responseStatus, 400)
89+
XCTAssertEqual(error.reason, .badDeviceToken)
90+
}
91+
}
92+
93+
func testSendAlert_missingTopicThrowsTypedError() async throws {
94+
let request = APNSRequest(
95+
message: Self.makeAlert(),
96+
deviceToken: Self.validDeviceToken,
97+
pushType: .alert,
98+
expiration: nil,
99+
priority: nil,
100+
apnsID: nil,
101+
topic: nil,
102+
collapseID: nil
103+
)
104+
do {
105+
_ = try await client.send(request)
106+
XCTFail("Expected an APNSError to be thrown")
107+
} catch let error as APNSError {
108+
XCTAssertEqual(error.responseStatus, 400)
109+
XCTAssertEqual(error.reason, .missingTopic)
110+
}
111+
}
112+
113+
func testSendAlert_unregisteredCarriesTimestamp() async throws {
114+
do {
115+
_ = try await client.sendAlertNotification(
116+
Self.makeAlert(),
117+
deviceToken: APNSTestServer.unregisteredDeviceToken
118+
)
119+
XCTFail("Expected an APNSError to be thrown")
120+
} catch let error as APNSError {
121+
XCTAssertEqual(error.responseStatus, 410)
122+
XCTAssertEqual(error.reason, .unregistered)
123+
let timestamp = try XCTUnwrap(error.timestamp)
124+
XCTAssertEqual(
125+
timestamp.timeIntervalSince1970,
126+
Double(APNSTestServer.unregisteredTimestampMilliseconds) / 1000,
127+
accuracy: 0.001
128+
)
129+
}
130+
}
131+
132+
// MARK: - Helpers
133+
134+
private static let validDeviceToken = String(repeating: "a", count: 64)
135+
136+
private static func makeAlert() -> APNSAlertNotification<EmptyPayload> {
137+
APNSAlertNotification(
138+
alert: .init(title: .raw("title")),
139+
expiration: .immediately,
140+
priority: .immediately,
141+
topic: "com.example.app",
142+
payload: EmptyPayload()
143+
)
144+
}
145+
146+
private static let jwtPrivateKey = """
147+
-----BEGIN PRIVATE KEY-----
148+
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg2sD+kukkA8GZUpmm
149+
jRa4fJ9Xa/JnIG4Hpi7tNO66+OGgCgYIKoZIzj0DAQehRANCAATZp0yt0btpR9kf
150+
ntp4oUUzTV0+eTELXxJxFvhnqmgwGAm1iVW132XLrdRG/ntlbQ1yzUuJkHtYBNve
151+
y+77Vzsd
152+
-----END PRIVATE KEY-----
153+
"""
154+
}

0 commit comments

Comments
 (0)