diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..ae6110e --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -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"]' diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml deleted file mode 100644 index 539e860..0000000 --- a/.github/workflows/testing.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Testing - -on: - pull_request: - branches: - - main - -jobs: - api_breakage: - name: Check API breakage - uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@main - - swiftlang_checks: - name: Swiftlang Checks - uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main - with: - license_header_check_project_name: "project" - format_check_enabled: true - broken_symlink_check_enabled: true - unacceptable_language_check_enabled: true - shell_check_enabled: false - docs_check_enabled: false - api_breakage_check_enabled: false - license_header_check_enabled: false - yamllint_check_enabled: false - python_lint_check_enabled: false - - bb_checks: - name: BB Checks - uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main - with: - local_swift_dependencies_check_enabled: true - run_tests_with_cache_enabled: true - headers_check_enabled: false - docc_warnings_check_enabled: true - run_tests_swift_versions: '["6.1", "6.2"]' diff --git a/Package.swift b/Package.swift index 8ae0415..dc5d4d8 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ @@ -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 ), ] ) diff --git a/README.md b/README.md index a0594d9..1d2d589 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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+ @@ -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: @@ -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/) @@ -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 diff --git a/Sources/FeatherPush/Client/PushClientError.swift b/Sources/FeatherPush/Client/PushClientError.swift index cdd6be5..aa22b6a 100644 --- a/Sources/FeatherPush/Client/PushClientError.swift +++ b/Sources/FeatherPush/Client/PushClientError.swift @@ -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) } diff --git a/Sources/FeatherPushTesting/TestPushClient.swift b/Sources/FeatherPushTesting/TestPushClient.swift new file mode 100644 index 0000000..a617d88 --- /dev/null +++ b/Sources/FeatherPushTesting/TestPushClient.swift @@ -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) + } + } +} diff --git a/Sources/FeatherPushTesting/TestPushState.swift b/Sources/FeatherPushTesting/TestPushState.swift new file mode 100644 index 0000000..286ba4d --- /dev/null +++ b/Sources/FeatherPushTesting/TestPushState.swift @@ -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)) + } +} diff --git a/Tests/FeatherPushTestingTests/TestPushClientTests.swift b/Tests/FeatherPushTestingTests/TestPushClientTests.swift new file mode 100644 index 0000000..b8f99a6 --- /dev/null +++ b/Tests/FeatherPushTestingTests/TestPushClientTests.swift @@ -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) + } +} diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile index 73be175..80800a9 100644 --- a/docker/tests/Dockerfile +++ b/docker/tests/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.1 +FROM swift:6.3 WORKDIR /app