Skip to content

Declarative Push Notification Tests #74

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/WebPush/Push Message/Notification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Contents>(
notification: PushMessage.Notification<Contents>,
to subscriber: some SubscriberProtocol,
deduplicationTopic topic: Topic? = nil,
expiration: Expiration = .recommendedMaximum,
Expand Down Expand Up @@ -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<Contents>(
notification: PushMessage.Notification<Contents>,
to subscriber: some SubscriberProtocol,
encodableDeduplicationTopic: some Encodable,
expiration: Expiration = .recommendedMaximum,
Expand Down
24 changes: 24 additions & 0 deletions Tests/WebPushTests/NeverTests.swift
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
187 changes: 187 additions & 0 deletions Tests/WebPushTests/NotificationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
83 changes: 81 additions & 2 deletions Tests/WebPushTests/WebPushManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -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"
)
}
}

Expand Down
Loading