diff --git a/Sources/WebPush/Push Message/Notification.swift b/Sources/WebPush/Push Message/Notification.swift index 62cc1c9..2ac6964 100644 --- a/Sources/WebPush/Push Message/Notification.swift +++ b/Sources/WebPush/Push Message/Notification.swift @@ -547,7 +547,7 @@ extension PushMessage { public enum CodingKeys: String, CodingKey { case id = "action" case label = "title" - case destination = "navigation" + case destination = "navigate" case icon } } diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 358143f..186f1cc 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -501,8 +501,8 @@ public actor WebPushManager: Sendable { /// - expiration: The expiration of the push message, after wich delivery will no longer be attempted. /// - urgency: The urgency of the delivery of the push message. /// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata. - public func send( - notification: some Encodable&Sendable, + public func send( + notification: PushMessage.Notification, to subscriber: some SubscriberProtocol, deduplicationTopic topic: Topic? = nil, expiration: Expiration = .recommendedMaximum, @@ -531,8 +531,8 @@ public actor WebPushManager: Sendable { /// - urgency: The urgency of the delivery of the push message. /// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata. @inlinable - public func send( - notification: some Encodable&Sendable, + public func send( + notification: PushMessage.Notification, to subscriber: some SubscriberProtocol, encodableDeduplicationTopic: some Encodable, expiration: Expiration = .recommendedMaximum, diff --git a/Tests/WebPushTests/NeverTests.swift b/Tests/WebPushTests/NeverTests.swift new file mode 100644 index 0000000..30814e1 --- /dev/null +++ b/Tests/WebPushTests/NeverTests.swift @@ -0,0 +1,24 @@ +// +// NeverTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2025-03-01. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import Testing +@testable import WebPush + +@Suite("Never Tests") +struct NeverTests { + @Test func retroactiveCodableWorks() async throws { + #expect(throws: DecodingError.self, performing: { + try JSONDecoder().decode(Never.self, from: Data("null".utf8)) + }) + } +} diff --git a/Tests/WebPushTests/NotificationTests.swift b/Tests/WebPushTests/NotificationTests.swift new file mode 100644 index 0000000..b15860b --- /dev/null +++ b/Tests/WebPushTests/NotificationTests.swift @@ -0,0 +1,187 @@ +// +// NotificationTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2025-03-01. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import Testing +@testable import WebPush + +@Suite("Push Message Notification") +struct NotificationTests { + @Test func simpleNotificationEncodesProperly() async throws { + let notification = PushMessage.Notification( + destination: URL(string: "https://jiiiii.moe")!, + title: "New Anime", + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self) + #expect(encodedString == """ + { + "notification" : { + "navigate" : "https://jiiiii.moe", + "timestamp" : 1000000000000, + "title" : "New Anime" + }, + "web_push" : 8030 + } + """) + + let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(encodedString.utf8)) + #expect(decodedNotification == notification) + } + + @Test func legacyNotificationEncodesProperly() async throws { + let notification = PushMessage.Notification( + kind: .legacy, + destination: URL(string: "https://jiiiii.moe")!, + title: "New Anime", + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self) + #expect(encodedString == """ + { + "notification" : { + "navigate" : "https://jiiiii.moe", + "timestamp" : 1000000000000, + "title" : "New Anime" + } + } + """) + + let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(encodedString.utf8)) + #expect(decodedNotification == notification) + } + + @Test func completeNotificationEncodesProperly() async throws { + let notification = PushMessage.Notification( + kind: .declarative, + destination: URL(string: "https://jiiiii.moe")!, + title: "New Anime", + body: "New anime is available!", + image: URL(string: "https://jiiiii.moe/animeImage")!, + actions: [ + PushMessage.NotificationAction( + id: "ok", + label: "OK", + destination: URL(string: "https://jiiiii.moe/ok")!, + icon: URL(string: "https://jiiiii.moe/okIcon") + ), + PushMessage.NotificationAction( + id: "cancel", + label: "Cancel", + destination: URL(string: "https://jiiiii.moe/cancel")!, + icon: URL(string: "https://jiiiii.moe/cancelIcon") + ), + ], + timestamp: Date(timeIntervalSince1970: 1_000_000_000), + appBadgeCount: 0, + isMutable: true, + options: PushMessage.NotificationOptions( + direction: .rightToLeft, + language: "jp", + tag: "new-anime", + icon: URL(string: "https://jiiiii.moe/icon")!, + badgeIcon: URL(string: "https://jiiiii.moe/badgeIcon")!, + vibrate: [200, 100, 200], + shouldRenotify: true, + isSilent: true, + requiresInteraction: true + ) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self) + #expect(encodedString == """ + { + "app_badge" : 0, + "mutable" : true, + "notification" : { + "actions" : [ + { + "action" : "ok", + "icon" : "https://jiiiii.moe/okIcon", + "navigate" : "https://jiiiii.moe/ok", + "title" : "OK" + }, + { + "action" : "cancel", + "icon" : "https://jiiiii.moe/cancelIcon", + "navigate" : "https://jiiiii.moe/cancel", + "title" : "Cancel" + } + ], + "badge" : "https://jiiiii.moe/badgeIcon", + "body" : "New anime is available!", + "dir" : "rtf", + "icon" : "https://jiiiii.moe/icon", + "image" : "https://jiiiii.moe/animeImage", + "lang" : "jp", + "navigate" : "https://jiiiii.moe", + "renotify" : true, + "require_interaction" : true, + "silent" : true, + "tag" : "new-anime", + "timestamp" : 1000000000000, + "title" : "New Anime", + "vibrate" : [ + 200, + 100, + 200 + ] + }, + "web_push" : 8030 + } + """) + + let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(encodedString.utf8)) + #expect(decodedNotification == notification) + } + + @Test func customNotificationEncodesProperly() async throws { + let notification = PushMessage.Notification( + destination: URL(string: "https://jiiiii.moe")!, + title: "New Anime", + timestamp: Date(timeIntervalSince1970: 1_000_000_000), + data: ["episodeID": "123"] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self) + #expect(encodedString == """ + { + "notification" : { + "data" : { + "episodeID" : "123" + }, + "navigate" : "https://jiiiii.moe", + "timestamp" : 1000000000000, + "title" : "New Anime" + }, + "web_push" : 8030 + } + """) + + let decodedNotification = try JSONDecoder().decode(type(of: notification), from: Data(encodedString.utf8)) + #expect(decodedNotification == notification) + } +} diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index 95d18c5..4d018a8 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -434,6 +434,64 @@ struct WebPushManagerTests { } } + @Test func sendSuccessfulNotification() async throws { + try await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let notification = PushMessage.Notification( + destination: URL(string: "https://jiiiii.moe")!, + title: "New Anime", + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + try validateAuthotizationHeader( + request: request, + vapidConfiguration: vapidConfiguration, + origin: "https://example.com" + ) + #expect(request.method == .POST) + #expect(request.headers["Content-Encoding"] == ["aes128gcm"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + #expect(request.headers["TTL"] == ["2592000"]) + #expect(request.headers["Urgency"] == ["high"]) + #expect(request.headers["Topic"] == []) + + let message = try await decrypt( + request: request, + userAgentPrivateKey: subscriberPrivateKey, + userAgentKeyMaterial: subscriber.userAgentKeyMaterial + ) + + let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(message)) + + #expect(decodedNotification == notification) + + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + try await manager.send( + notification: notification, + to: subscriber + ) + } + } + @Test func sendSuccessfulMultipleMessages() async throws { try await confirmation(expectedCount: 3) { requestWasMade in let manager = WebPushManager( @@ -452,7 +510,7 @@ struct WebPushManagerTests { } @Test func sendCustomTopic() async throws { - try await confirmation(expectedCount: 6) { requestWasMade in + try await confirmation(expectedCount: 8) { requestWasMade in let vapidConfiguration = VAPID.Configuration.makeTesting() let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) @@ -468,6 +526,12 @@ struct WebPushManagerTests { var logger = Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) logger.logLevel = .trace + let notification = PushMessage.Notification( + destination: URL(string: "https://jiiiii.moe")!, + title: "New Anime", + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ) + let manager = WebPushManager( vapidConfiguration: vapidConfiguration, networkConfiguration: .init(alwaysResolveTopics: true), @@ -491,7 +555,12 @@ struct WebPushManagerTests { userAgentKeyMaterial: subscriber.userAgentKeyMaterial ) - #expect(String(decoding: message, as: UTF8.self) == "\"hello\"") + if message.count == 7 { + #expect(String(decoding: message, as: UTF8.self) == #""hello""#) + } else { + let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(message)) + #expect(decodedNotification == notification) + } requestWasMade() return HTTPClientResponse(status: .created) @@ -528,6 +597,16 @@ struct WebPushManagerTests { to: subscriber, encodableDeduplicationTopic: "topic-id" ) + try await manager.send( + notification: notification, + to: subscriber, + deduplicationTopic: Topic(encodableTopic: "topic-id", salt: subscriber.userAgentKeyMaterial.authenticationSecret) + ) + try await manager.send( + notification: notification, + to: subscriber, + encodableDeduplicationTopic: "topic-id" + ) } }