Skip to content
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
31 changes: 31 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Pull request

on:
pull_request:
branches:
- main

jobs:

soundness:
name: Soundness
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.15
with:
license_header_check_project_name: "project"
format_check_enabled : true
broken_symlink_check_enabled : true
unacceptable_language_check_enabled : true
shell_check_enabled : true
docs_check_enabled : false
api_breakage_check_enabled : false
license_header_check_enabled : false
yamllint_check_enabled : false
python_lint_check_enabled : false

tests:
name: Tests
uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@0.0.15
with:
enable_windows_checks : false
linux_build_command: "swift test --parallel --enable-code-coverage"
linux_swift_versions: '["6.3", "6.4"]'
36 changes: 0 additions & 36 deletions .github/workflows/testing.yml

This file was deleted.

52 changes: 32 additions & 20 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
// swift-tools-version:6.1
// swift-tools-version:6.3
import PackageDescription

// NOTE: https://github.com/swift-server/swift-http-server/blob/main/Package.swift
var defaultSwiftSettings: [SwiftSetting] = [
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0441-formalize-language-mode-terminology.md
let swiftSettings: [SwiftSetting] = [
.swiftLanguageMode(.v6),
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.enableUpcomingFeature("InternalImportsByDefault"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("MemberImportVisibility"),
// https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638
.enableUpcomingFeature("InferIsolatedConformances"),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("ImmutableWeakCaptures"),
.enableExperimentalFeature("SuppressedAssociatedTypes"),
.enableExperimentalFeature("LifetimeDependence"),
.enableExperimentalFeature("Lifetimes"),
// https://github.com/swiftlang/swift/pull/65218
.enableExperimentalFeature("AvailabilityMacro=featherPush:macOS 15, iOS 18, watchOS 11, tvOS 11, visionOS 2"),
.enableUpcomingFeature("StrictConcurrency"),
]

#if compiler(>=6.2)
defaultSwiftSettings.append(
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md
.enableUpcomingFeature("NonisolatedNonsendingByDefault")
)
#endif

let package = Package(
name: "feather-push",
platforms: [
Expand All @@ -31,22 +28,37 @@ let package = Package(
],
products: [
.library(name: "FeatherPush", targets: ["FeatherPush"]),
.library(name: "FeatherPushTesting", targets: ["FeatherPushTesting"]),
],
dependencies: [
// [docc-plugin-placeholder]
],
dependencies: [],
targets: [
.target(
name: "FeatherPush",
dependencies: [],
swiftSettings: defaultSwiftSettings
swiftSettings: swiftSettings
),
.target(
name: "FeatherPushTesting",
dependencies: [
.target(name: "FeatherPush"),
],
path: "Sources/FeatherPushTesting",
swiftSettings: swiftSettings
),
.testTarget(
name: "FeatherPushTests",
dependencies: [
.target(name: "FeatherPush"),
],
swiftSettings: defaultSwiftSettings
swiftSettings: swiftSettings
),
.testTarget(
name: "FeatherPushTestingTests",
dependencies: [
.target(name: "FeatherPush"),
.target(name: "FeatherPushTesting"),
],
swiftSettings: swiftSettings
),
]
)
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

An abstract push notification client for Feather CMS.

[![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)](https://github.com/feather-framework/feather-push/releases/tag/1.0.0-beta.2)
[![Release: 1.0.0-rc.1](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E1-F05138)](https://github.com/feather-framework/feather-push/releases/tag/1.0.0-rc.1)

## Features

Expand All @@ -14,10 +14,10 @@ An abstract push notification client for Feather CMS.

## Requirements

![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%2B-F05138)
![Swift 6.3+](https://img.shields.io/badge/Swift-6%2E3%2B-F05138)
![Platforms: Linux, macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-Linux_%7C_macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138)

- Swift 6.1+
- Swift 6.3+
- Platforms:
- Linux
- macOS 15+
Expand All @@ -31,7 +31,7 @@ An abstract push notification client for Feather CMS.
Use Swift Package Manager; add the dependency to your `Package.swift` file:

```swift
.package(url: "https://github.com/feather-framework/feather-push", exact: "1.0.0-beta.2"),
.package(url: "https://github.com/feather-framework/feather-push", exact: "1.0.0-rc.1"),
```

Then add `FeatherPush` to your target dependencies:
Expand All @@ -40,6 +40,15 @@ Then add `FeatherPush` to your target dependencies:
.product(name: "FeatherPush", package: "feather-push"),
```

For in-memory test support, add `FeatherPushTesting` to your test target:

```swift
.product(name: "FeatherPushTesting", package: "feather-push"),
```

`TestPushClient` records notifications and destinations in a shared,
actor-isolated `TestPushState` that tests can inspect and clear.

## Usage

[![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)](https://feather-framework.github.io/feather-push/)
Expand Down Expand Up @@ -93,7 +102,6 @@ The following push client implementations are available for use:

- [Feather Push APNS](https://github.com/feather-framework/feather-push-apns)
- [Feather Push FCM](https://github.com/feather-framework/feather-push-fcm)
- [Feather Push Ephemeral](https://github.com/feather-framework/feather-push-ephemeral)

## Development

Expand Down
2 changes: 1 addition & 1 deletion Sources/FeatherPush/Client/PushClientError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ public enum PushClientError: Error {
/// An underlying error without a more specific provider-neutral mapping.
///
/// - Parameter error: The underlying provider or transport error.
case unknown(Error)
case unknown(any Error)
}
47 changes: 47 additions & 0 deletions Sources/FeatherPushTesting/TestPushClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
public import FeatherPush

/// An in-memory `PushClient` that captures notifications for tests.
public struct TestPushClient: Sendable {

/// The actor-isolated state containing captured notifications.
public let state: TestPushState

/// Creates a test client using the supplied or a new state.
public init(state: TestPushState = TestPushState()) {
self.state = state
}

/// Returns captured notifications and their destinations in insertion order.
public func getNotifications() async -> [(PushNotification, String)] {
await state.getNotifications()
}

/// Removes all captured notifications.
public func clearNotifications() async {
await state.clear()
}
}

extension TestPushClient: PushClient {

/// Captures a notification for the supplied device token or topic.
///
/// - Parameters:
/// - notification: The notification content and delivery options.
/// - target: The device token or topic to capture with the notification.
/// - Throws: `PushClientError.invalidTopic` or
/// `PushClientError.invalidDeviceToken` if the target is empty.
public func send(
notification: PushNotification,
to target: PushDeliveryTarget
) async throws(PushClientError) {
switch target {
case .topic(let topic):
guard !topic.isEmpty else { throw .invalidTopic }
await state.add(notification, destination: topic)
case .deviceToken(let deviceToken):
guard !deviceToken.isEmpty else { throw .invalidDeviceToken }
await state.add(notification, destination: deviceToken)
}
}
}
24 changes: 24 additions & 0 deletions Sources/FeatherPushTesting/TestPushState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
public import FeatherPush

/// Actor-isolated notification storage used by `TestPushClient`.
public actor TestPushState {

private var notifications: [(PushNotification, String)] = []

/// Creates an empty test state.
public init() {}

/// Returns captured notifications and their destinations in insertion order.
public func getNotifications() -> [(PushNotification, String)] {
notifications
}

/// Removes all captured notifications.
public func clear() {
notifications.removeAll()
}

func add(_ notification: PushNotification, destination: String) {
notifications.append((notification, destination))
}
}
79 changes: 79 additions & 0 deletions Tests/FeatherPushTestingTests/TestPushClientTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import FeatherPush
import FeatherPushTesting
import Testing

@Suite
struct TestPushClientTests {

@Test
func sendCapturesNotificationsAndDestinationsInOrder() async throws {
let state = TestPushState()
let client = TestPushClient(state: state)
let first = PushNotification(title: "First", body: "Body")
let second = PushNotification(title: "Second", body: "Body")

try await client.send(notification: first, to: .topic("news"))
try await client.send(
notification: second,
to: .deviceToken("device-token")
)

let captured = await state.getNotifications()
#expect(captured.count == 2)
#expect(captured[0].0.title == "First")
#expect(captured[0].1 == "news")
#expect(captured[1].0.title == "Second")
#expect(captured[1].1 == "device-token")
}

@Test
func sendRejectsEmptyTopicWithoutCapturingNotification() async {
let client = TestPushClient()

do {
try await client.send(
notification: PushNotification(title: "Title", body: "Body"),
to: .topic("")
)
}
catch .invalidTopic {
// Expected error.
}
catch {
Issue.record("Unexpected error: \(error)")
}
#expect(await client.getNotifications().isEmpty)
}

@Test
func sendRejectsEmptyDeviceTokenWithoutCapturingNotification() async {
let client = TestPushClient()

do {
try await client.send(
notification: PushNotification(title: "Title", body: "Body"),
to: .deviceToken("")
)
}
catch .invalidDeviceToken {
// Expected error.
}
catch {
Issue.record("Unexpected error: \(error)")
}
#expect(await client.getNotifications().isEmpty)
}

@Test
func clearNotificationsRemovesCapturedNotifications() async throws {
let client = TestPushClient()
try await client.send(
notification: PushNotification(title: "Title", body: "Body"),
to: .topic("news")
)

await client.clearNotifications()

#expect(await client.getNotifications().isEmpty)
}
}
2 changes: 1 addition & 1 deletion docker/tests/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM swift:6.1
FROM swift:6.3

WORKDIR /app

Expand Down
Loading