diff --git a/Sources/APNSTestServer/APNSTestServer.swift b/Sources/APNSTestServer/APNSTestServer.swift index 475078d..9306dd5 100644 --- a/Sources/APNSTestServer/APNSTestServer.swift +++ b/Sources/APNSTestServer/APNSTestServer.swift @@ -86,6 +86,12 @@ public final class APNSTestServer: @unchecked Sendable { } } + /// A valid-hex device token that the server treats as unregistered, responding with `410 Unregistered`. + public static let unregisteredDeviceToken = String(repeating: "f", count: 64) + + /// The `timestamp` (milliseconds since epoch) returned alongside a simulated `410 Unregistered` response. + public static let unregisteredTimestampMilliseconds = 1_454_096_879_000 + public init() { self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) } @@ -268,6 +274,15 @@ public final class APNSTestServer: @unchecked Sendable { return (.badRequest, responseHeaders, "{\"reason\":\"BadDeviceToken\"}") } + // Simulate an unregistered token: a valid-hex token equal to `Self.unregisteredDeviceToken` + // responds with `410 Unregistered` and a `timestamp`, mirroring Apple's behaviour so the + // `APNSError.timestamp` decoding path can be exercised. + if deviceToken == Self.unregisteredDeviceToken { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.gone, responseHeaders, "{\"reason\":\"Unregistered\",\"timestamp\":\(Self.unregisteredTimestampMilliseconds)}") + } + // Validate required topic header guard headers.contains(name: "apns-topic") else { var responseHeaders = HTTPHeaders() diff --git a/Tests/APNSTests/APNSClientSendTests.swift b/Tests/APNSTests/APNSClientSendTests.swift new file mode 100644 index 0000000..3e956a6 --- /dev/null +++ b/Tests/APNSTests/APNSClientSendTests.swift @@ -0,0 +1,154 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import APNSCore +import APNS +import APNSTestServer +import Crypto +import NIOPosix +import XCTest + +/// Exercises the NIO-based ``APNSClient`` send path end-to-end against ``APNSTestServer``. +final class APNSClientSendTests: XCTestCase { + var server: APNSTestServer! + var client: APNSClient! + + override func setUp() async throws { + try await super.setUp() + + server = APNSTestServer() + try await server.start(port: 0) + + client = APNSClient( + configuration: .init( + authenticationMethod: .jwt( + privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey), + keyIdentifier: "MY_KEY_ID", + teamIdentifier: "MY_TEAM_ID" + ), + environment: .custom(url: "http://127.0.0.1", port: server.port) + ), + eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton), + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder() + ) + } + + override func tearDown() async throws { + try await client?.shutdown() + try await server?.shutdown() + client = nil + server = nil + try await super.tearDown() + } + + func testSendAlert_success() async throws { + let response = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken) + + XCTAssertNotNil(response.apnsID) + XCTAssertEqual(server.getSentNotifications().count, 1) + } + + func testSendAlert_propagatesAllHeaders() async throws { + var alert = APNSAlertNotification( + alert: .init(title: .raw("title")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload() + ) + alert.collapseID = "collapse-123" + _ = try await client.sendAlertNotification(alert, deviceToken: Self.validDeviceToken) + + let sent = try XCTUnwrap(server.getSentNotifications().first) + XCTAssertEqual(sent.deviceToken, Self.validDeviceToken) + XCTAssertEqual(sent.pushType, "alert") + XCTAssertEqual(sent.topic, "com.example.app") + XCTAssertEqual(sent.priority, "10") + XCTAssertEqual(sent.expiration, "0") + XCTAssertEqual(sent.collapseID, "collapse-123") + } + + func testSendAlert_badDeviceTokenThrowsTypedError() async throws { + do { + _ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: "not-valid") + XCTFail("Expected an APNSError to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 400) + XCTAssertEqual(error.reason, .badDeviceToken) + } + } + + func testSendAlert_missingTopicThrowsTypedError() async throws { + let request = APNSRequest( + message: Self.makeAlert(), + deviceToken: Self.validDeviceToken, + pushType: .alert, + expiration: nil, + priority: nil, + apnsID: nil, + topic: nil, + collapseID: nil + ) + do { + _ = try await client.send(request) + XCTFail("Expected an APNSError to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 400) + XCTAssertEqual(error.reason, .missingTopic) + } + } + + func testSendAlert_unregisteredCarriesTimestamp() async throws { + do { + _ = try await client.sendAlertNotification( + Self.makeAlert(), + deviceToken: APNSTestServer.unregisteredDeviceToken + ) + XCTFail("Expected an APNSError to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 410) + XCTAssertEqual(error.reason, .unregistered) + let timestamp = try XCTUnwrap(error.timestamp) + XCTAssertEqual( + timestamp.timeIntervalSince1970, + Double(APNSTestServer.unregisteredTimestampMilliseconds) / 1000, + accuracy: 0.001 + ) + } + } + + // MARK: - Helpers + + private static let validDeviceToken = String(repeating: "a", count: 64) + + private static func makeAlert() -> APNSAlertNotification { + APNSAlertNotification( + alert: .init(title: .raw("title")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload() + ) + } + + private static let jwtPrivateKey = """ + -----BEGIN PRIVATE KEY----- + MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg2sD+kukkA8GZUpmm + jRa4fJ9Xa/JnIG4Hpi7tNO66+OGgCgYIKoZIzj0DAQehRANCAATZp0yt0btpR9kf + ntp4oUUzTV0+eTELXxJxFvhnqmgwGAm1iVW132XLrdRG/ntlbQ1yzUuJkHtYBNve + y+77Vzsd + -----END PRIVATE KEY----- + """ +}