feat: update iOS notification test and plugin implementation

- Update RunnerTests.swift with notification test cases
- Update NexoMessagingPlugin.swift with notification handling
- Update ios-notification-test-guide.md
This commit is contained in:
toki 2026-06-01 13:38:32 +09:00
parent 908a4ecc18
commit 68ccfb13fd
3 changed files with 267 additions and 13 deletions

View file

@ -5,12 +5,24 @@ import XCTest
@testable import nexo_messaging
// This demonstrates a simple unit test of the Swift portion of this plugin's implementation.
// Unit tests for the iOS portion of the nexo messaging plugin bridge.
//
// These cover the minimum iOS method contract and stream handler. They are
// deterministic and credential-free; APNs registration, Firebase token
// refresh, notification center delegate, ACK, dismiss, and inline reply remain
// future smoke/implementation work.
//
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
class RunnerTests: XCTestCase {
private let deviceTokenKey = "nexo.messaging.deviceToken"
override func tearDown() {
UserDefaults.standard.removeObject(forKey: deviceTokenKey)
super.tearDown()
}
func testGetPlatformVersion() {
let plugin = NexoMessagingPlugin()
@ -24,4 +36,150 @@ class RunnerTests: XCTestCase {
waitForExpectations(timeout: 1)
}
func testDebugSendNativeEventForwardsPayloadToEventSink() {
let plugin = NexoMessagingPlugin()
var forwarded: Any?
_ = plugin.onListen(withArguments: nil, eventSink: { event in
forwarded = event
})
let call = FlutterMethodCall(methodName: "debugSendNativeEvent", arguments: [
"type": "opened",
"channel_id": "channel-ios",
])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertNil(result)
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
XCTAssertEqual((forwarded as? [String: Any])?["channel_id"] as? String, "channel-ios")
}
func testDebugSendNativeEventWithNullPayloadReturnsInvalidArg() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "debugSendNativeEvent", arguments: nil)
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG")
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testSaveAndGetDeviceToken() {
let plugin = NexoMessagingPlugin()
let saveCall = FlutterMethodCall(methodName: "saveDeviceToken", arguments: [
"token": "apple_rn-v2:smoke-token",
])
let saveExpectation = expectation(description: "save result block must be called.")
plugin.handle(saveCall) { result in
XCTAssertNil(result)
saveExpectation.fulfill()
}
let getCall = FlutterMethodCall(methodName: "getDeviceToken", arguments: nil)
let getExpectation = expectation(description: "get result block must be called.")
plugin.handle(getCall) { result in
XCTAssertEqual(result as? String, "apple_rn-v2:smoke-token")
getExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testSaveDeviceTokenWithNullTokenReturnsInvalidArg() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "saveDeviceToken", arguments: [:])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG")
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testSetAuthTokenSuccessNoOp() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "setAuthToken", arguments: [
"serverUrl": "https://smoke.example",
"token": "auth-token",
])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertNil(result)
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testSetAuthTokenMissingArgReturnsInvalidArg() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "setAuthToken", arguments: [
"serverUrl": "https://smoke.example",
])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG")
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testSetSigningKeySuccessNoOp() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "setSigningKey", arguments: [
"serverUrl": "https://smoke.example",
"signingKey": "signing-key",
])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertNil(result)
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testClearAuthTokenSuccessNoOp() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "clearAuthToken", arguments: [
"serverUrl": "https://smoke.example",
])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertNil(result)
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
func testClearAuthTokenMissingArgReturnsInvalidArg() {
let plugin = NexoMessagingPlugin()
let call = FlutterMethodCall(methodName: "clearAuthToken", arguments: [:])
let resultExpectation = expectation(description: "result block must be called.")
plugin.handle(call) { result in
XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG")
resultExpectation.fulfill()
}
waitForExpectations(timeout: 1)
}
}

View file

@ -14,9 +14,21 @@ secret-store location.
## Current Repository State
- The iOS plugin is scaffold-only. `packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift`
currently registers a `nexo_messaging` method channel and implements only
`getPlatformVersion`.
- The iOS plugin now registers the minimum debug/action bridge.
`packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift` registers
the legacy `nexo_messaging` method channel (for `getPlatformVersion`
compatibility), the
`com.tokilabs.nexo.messaging/notification_actions` method channel, and the
`com.tokilabs.nexo.messaging/notifications` event channel.
- The iOS bridge implements `debugSendNativeEvent`, `saveDeviceToken`,
`getDeviceToken`, `setAuthToken`, `clearAuthToken`, `setSigningKey`, and
`getPlatformVersion`. `saveDeviceToken`/`getDeviceToken` persist only the
formatted token string via `UserDefaults`; `setAuthToken`, `clearAuthToken`,
and `setSigningKey` are validation-only no-ops that do not persist auth tokens
or signing secrets on iOS yet.
- This is a debug/local-routing bridge only. APNs registration, Firebase token
refresh handoff, notification center delegate, ACK, dismiss, and inline reply
are not implemented and remain future smoke/implementation work.
- The Dart plugin expects native notification and action channels:
`com.tokilabs.nexo.messaging/notifications` and
`com.tokilabs.nexo.messaging/notification_actions`.
@ -36,8 +48,11 @@ The first meaningful iOS smoke target is therefore:
5. Deliver one visible FCM notification through APNs.
6. Map a notification open into the Dart `NotificationOpenedEvent` contract.
ACK, dismiss, inline reply, and native notification persistence are follow-up
implementation work unless the iOS native bridge is expanded first.
The minimum iOS debug/action bridge (channel registration plus the method
contract above) now exists, so Level 0 contract tests and local injection can
exercise the Dart routing path. APNs registration, FCM token handoff, ACK,
dismiss, inline reply, and native notification persistence are still follow-up
implementation work.
## Decisions Needed Before Testing
@ -166,16 +181,19 @@ Do not place service-account JSON in the repo.
## Native Bridge Work Needed For A Full Smoke
The current iOS scaffold cannot yet complete the Dart notification contract.
Minimum native bridge work:
The minimum debug/action bridge is in place, but the iOS native side still
cannot complete a full APNs/FCM delivery smoke. Already done:
- Register the expected EventChannel:
`com.tokilabs.nexo.messaging/notifications`.
- Register the expected MethodChannel:
`com.tokilabs.nexo.messaging/notification_actions`.
- Implement `saveDeviceToken`, `getDeviceToken`, `setAuthToken`,
`clearAuthToken`, `setSigningKey`, and `debugSendNativeEvent` for iOS or
explicitly no-op them with clear smoke limitations.
`clearAuthToken`, `setSigningKey`, and `debugSendNativeEvent` for iOS, with
auth token/signing key kept as validation-only no-ops for now.
Remaining native bridge work:
- Request user notification authorization or rely consistently on
FlutterFire's `FirebaseMessaging.requestPermission`.
- Call `UIApplication.shared.registerForRemoteNotifications()`.

View file

@ -1,19 +1,97 @@
import Flutter
import UIKit
public class NexoMessagingPlugin: NSObject, FlutterPlugin {
private enum NexoMessagingChannels {
static let legacy = "nexo_messaging"
static let events = "com.tokilabs.nexo.messaging/notifications"
static let actions = "com.tokilabs.nexo.messaging/notification_actions"
}
private enum NexoMessagingKeys {
static let deviceToken = "nexo.messaging.deviceToken"
}
public class NexoMessagingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private var eventSink: FlutterEventSink?
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "nexo_messaging", binaryMessenger: registrar.messenger())
let instance = NexoMessagingPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
let messenger = registrar.messenger()
let legacyChannel = FlutterMethodChannel(
name: NexoMessagingChannels.legacy,
binaryMessenger: messenger
)
registrar.addMethodCallDelegate(instance, channel: legacyChannel)
let actionChannel = FlutterMethodChannel(
name: NexoMessagingChannels.actions,
binaryMessenger: messenger
)
registrar.addMethodCallDelegate(instance, channel: actionChannel)
let eventChannel = FlutterEventChannel(
name: NexoMessagingChannels.events,
binaryMessenger: messenger
)
eventChannel.setStreamHandler(instance)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "debugSendNativeEvent":
guard let payload = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARG", message: "event payload is null", details: nil))
return
}
eventSink?(payload)
result(nil)
case "saveDeviceToken":
guard let token = argument("token", from: call) else {
result(FlutterError(code: "INVALID_ARG", message: "token is null", details: nil))
return
}
UserDefaults.standard.set(token, forKey: NexoMessagingKeys.deviceToken)
result(nil)
case "getDeviceToken":
result(UserDefaults.standard.string(forKey: NexoMessagingKeys.deviceToken))
case "setAuthToken":
guard argument("serverUrl", from: call) != nil, argument("token", from: call) != nil else {
result(FlutterError(code: "INVALID_ARG", message: "serverUrl or token is null", details: nil))
return
}
result(nil)
case "setSigningKey":
guard argument("serverUrl", from: call) != nil, argument("signingKey", from: call) != nil else {
result(FlutterError(code: "INVALID_ARG", message: "serverUrl or signingKey is null", details: nil))
return
}
result(nil)
case "clearAuthToken":
guard argument("serverUrl", from: call) != nil else {
result(FlutterError(code: "INVALID_ARG", message: "serverUrl is null", details: nil))
return
}
result(nil)
case "getPlatformVersion":
result("iOS " + UIDevice.current.systemVersion)
default:
result(FlutterMethodNotImplemented)
}
}
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
private func argument(_ name: String, from call: FlutterMethodCall) -> String? {
guard let arguments = call.arguments as? [String: Any] else { return nil }
return arguments[name] as? String
}
}