Aligns the iOS app with the Clawnet refactor by implementing proper role separation for gateway connections. Uses separate operator and node sessions to match the gateway's authorization requirements. Changes: - New GatewayOperatorSession: Wraps GatewayChannelActor for operator-role RPC requests (chat.*, health, sessions.list) without invoke handling - Dual-connection architecture: Operator session for requests, node session for node.event calls (e.g., chat.subscribe) - Separate websocket sessions: Each connection gets its own URLSession to prevent response cross-talk - Updated chat transport: IOSGatewayChatTransport uses operator session for requests, node session for subscriptions ClawdbotKit (shared): - Deadlock fix in GatewayChannel.swift: Moved connection finalization (listen(), connected=true, isConnecting=false, waiter resumption) to occur before calling pushHandler. This fixes a latent bug where requests made from onConnected callbacks would deadlock. Does not affect macOS (its callback doesn't make requests). - Package.swift: Fixed argument order for Swift 6.2 compatibility iOS chat is now working. This is the base PR to unlock further work on the iOS app.
147 lines
5.2 KiB
Swift
147 lines
5.2 KiB
Swift
import MoltbotKit
|
|
import MoltbotProtocol
|
|
import Foundation
|
|
import Testing
|
|
@testable import Moltbot
|
|
|
|
@Suite(.serialized) struct GatewayPairingStateTests {
|
|
@Test @MainActor func pairingPendingTransitionsAcrossRoles() {
|
|
let appModel = NodeAppModel()
|
|
|
|
#expect(appModel.gatewayPairingState == .none)
|
|
|
|
appModel._test_updatePairingPending(role: .operator, reason: "Pairing required")
|
|
#expect(appModel.gatewayPairingState == .operatorPending)
|
|
|
|
appModel._test_updatePairingPending(role: .node, reason: "Awaiting approval")
|
|
#expect(appModel.gatewayPairingState == .bothPending)
|
|
|
|
appModel._test_updatePairingPending(role: .operator, reason: "Disconnected")
|
|
#expect(appModel.gatewayPairingState == .nodePending)
|
|
|
|
appModel._test_clearPairingPending()
|
|
#expect(appModel.gatewayPairingState == .none)
|
|
}
|
|
|
|
@Test @MainActor func pairingPendingClearsOnEmptyReason() {
|
|
let appModel = NodeAppModel()
|
|
appModel._test_setPairingPending(role: .node, pending: true)
|
|
#expect(appModel.gatewayPairingState == .nodePending)
|
|
|
|
appModel._test_updatePairingPending(role: .node, reason: " ")
|
|
#expect(appModel.gatewayPairingState == .none)
|
|
}
|
|
|
|
@Test @MainActor func pairingRequestedEventSetsPendingForOperatorRole() async {
|
|
let appModel = NodeAppModel()
|
|
let myDeviceId = "test-device-123"
|
|
|
|
let payload: [String: MoltbotProtocol.AnyCodable] = [
|
|
"deviceId": MoltbotProtocol.AnyCodable(myDeviceId),
|
|
"role": MoltbotProtocol.AnyCodable("operator"),
|
|
]
|
|
let evt = EventFrame(
|
|
type: "event",
|
|
event: "device.pair.requested",
|
|
payload: MoltbotProtocol.AnyCodable(payload),
|
|
seq: nil,
|
|
stateversion: nil)
|
|
|
|
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
|
|
|
|
#expect(appModel.gatewayPairingState == .operatorPending)
|
|
}
|
|
|
|
@Test @MainActor func pairingRequestedEventSetsPendingForNodeRole() async {
|
|
let appModel = NodeAppModel()
|
|
let myDeviceId = "test-device-456"
|
|
|
|
let payload: [String: MoltbotProtocol.AnyCodable] = [
|
|
"deviceId": MoltbotProtocol.AnyCodable(myDeviceId),
|
|
"role": MoltbotProtocol.AnyCodable("node"),
|
|
]
|
|
let evt = EventFrame(
|
|
type: "event",
|
|
event: "device.pair.requested",
|
|
payload: MoltbotProtocol.AnyCodable(payload),
|
|
seq: nil,
|
|
stateversion: nil)
|
|
|
|
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
|
|
|
|
#expect(appModel.gatewayPairingState == .nodePending)
|
|
}
|
|
|
|
@Test @MainActor func pairingRequestedEventIgnoresOtherDevices() async {
|
|
let appModel = NodeAppModel()
|
|
let myDeviceId = "test-device-mine"
|
|
|
|
let payload: [String: MoltbotProtocol.AnyCodable] = [
|
|
"deviceId": MoltbotProtocol.AnyCodable("different-device"),
|
|
"role": MoltbotProtocol.AnyCodable("operator"),
|
|
]
|
|
let evt = EventFrame(
|
|
type: "event",
|
|
event: "device.pair.requested",
|
|
payload: MoltbotProtocol.AnyCodable(payload),
|
|
seq: nil,
|
|
stateversion: nil)
|
|
|
|
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
|
|
|
|
#expect(appModel.gatewayPairingState == .none)
|
|
}
|
|
|
|
@Test @MainActor func pairingResolvedEventClearsPending() async {
|
|
let appModel = NodeAppModel()
|
|
let myDeviceId = "test-device-789"
|
|
|
|
// First set pending state
|
|
appModel._test_setPairingPending(role: .operator, pending: true)
|
|
appModel._test_setPairingPending(role: .node, pending: true)
|
|
#expect(appModel.gatewayPairingState == .bothPending)
|
|
|
|
// Receive resolved event
|
|
let payload: [String: MoltbotProtocol.AnyCodable] = [
|
|
"deviceId": MoltbotProtocol.AnyCodable(myDeviceId),
|
|
"decision": MoltbotProtocol.AnyCodable("approved"),
|
|
]
|
|
let evt = EventFrame(
|
|
type: "event",
|
|
event: "device.pair.resolved",
|
|
payload: MoltbotProtocol.AnyCodable(payload),
|
|
seq: nil,
|
|
stateversion: nil)
|
|
|
|
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
|
|
|
|
#expect(appModel.gatewayPairingState == .none)
|
|
}
|
|
|
|
@Test @MainActor func pairingResolvedEventIgnoresOtherDevices() async {
|
|
let appModel = NodeAppModel()
|
|
let myDeviceId = "test-device-mine"
|
|
|
|
// Set pending state
|
|
appModel._test_setPairingPending(role: .operator, pending: true)
|
|
#expect(appModel.gatewayPairingState == .operatorPending)
|
|
|
|
// Receive resolved event for different device
|
|
let payload: [String: MoltbotProtocol.AnyCodable] = [
|
|
"deviceId": MoltbotProtocol.AnyCodable("different-device"),
|
|
"decision": MoltbotProtocol.AnyCodable("approved"),
|
|
]
|
|
let evt = EventFrame(
|
|
type: "event",
|
|
event: "device.pair.resolved",
|
|
payload: MoltbotProtocol.AnyCodable(payload),
|
|
seq: nil,
|
|
stateversion: nil)
|
|
|
|
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
|
|
|
|
// State unchanged
|
|
#expect(appModel.gatewayPairingState == .operatorPending)
|
|
}
|
|
}
|