import APIClient import Foundation import HostRegistry import Testing import TestSupport import UserNotifications import WireProtocol @testable import WebTerm /// T-iOS-21 · NotificationActionHandler.handle:Allow/Deny → POST /// /hook/decision(背景任务包住、403 → 本地通知兜底)、默认点按 → /// DeepLinkRouter 同源路由到会话(parse 的 payload 级用例在 /// NotificationActionParseTests.swift)。 /// 说明:UNNotificationResponse 无法在单测构造 —— didReceive 薄胶水不在 /// 单测覆盖内,全部逻辑经 parse(actionIdentifier:userInfo:) + handle(_:) 测。 @MainActor @Suite("NotificationActionHandler") struct NotificationActionHandlerTests { // MARK: - Fakes @MainActor private final class FakeBackgroundTasks: BackgroundTaskRunning { private(set) var begunNames: [String] = [] private(set) var endedTokens: [Int] = [] private var nextToken = 0 func begin(name: String) -> Int { begunNames.append(name) nextToken += 1 return nextToken } func end(_ token: Int) { endedTokens.append(token) } } @MainActor private final class FakeNoticePoster: LocalNoticePosting { private(set) var posted: [(title: String, body: String)] = [] func post(title: String, body: String) async { posted.append((title, body)) } } @MainActor private final class OpenRecorder { private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = [] var actions: NotificationActionHandler.Actions { NotificationActionHandler.Actions(openSession: { [weak self] host, sessionId in self?.opened.append((host, sessionId)) }) } } // MARK: - Fixtures private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a" /// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。 private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f" private static let hostABase = "http://192.168.1.5:3000" private static let hostBBase = "http://192.168.1.6:3000" private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host { let url = try #require(URL(string: base)) let endpoint = try #require(HostEndpoint(baseURL: url)) return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint) } private struct Harness { let handler: NotificationActionHandler let http: FakeHTTPTransport let backgroundTasks: FakeBackgroundTasks let notices: FakeNoticePoster let recorder: OpenRecorder } private static func makeHarness(hosts: [HostRegistry.Host]) -> Harness { let http = FakeHTTPTransport() let backgroundTasks = FakeBackgroundTasks() let notices = FakeNoticePoster() let recorder = OpenRecorder() let handler = NotificationActionHandler( hostStore: InMemoryHostStore(hosts: hosts), http: http, backgroundTasks: backgroundTasks, notices: notices, actions: recorder.actions ) return Harness( handler: handler, http: http, backgroundTasks: backgroundTasks, notices: notices, recorder: recorder ) } private static func decisionURL(base: String) throws -> URL { try #require(URL(string: "\(base)/hook/decision")) } private static func liveSessionsURL(base: String) throws -> URL { try #require(URL(string: "\(base)/live-sessions")) } /// 多主机场景的两条 RO 探查响应一次排好(A 无、B 持有目标会话)。 private static func queueTwoHostProbe(_ harness: Harness) async throws { await harness.http.queueSuccess( method: "GET", url: try liveSessionsURL(base: hostABase), body: liveSessionsBody(containing: nil) ) await harness.http.queueSuccess( method: "GET", url: try liveSessionsURL(base: hostBBase), body: liveSessionsBody(containing: sessionId) ) } /// /live-sessions 响应体:可选包含目标会话(必填字段齐全的最小形状)。 private static func liveSessionsBody(containing sessionId: String?) -> Data { guard let sessionId else { return Data("[]".utf8) } let json = """ [{"id":"\(sessionId)","createdAt":1,"clientCount":0,"status":"waiting", "exited":false,"cols":80,"rows":24}] """ return Data(json.utf8) } // MARK: - handle(.decision):POST /hook/decision @Test("单主机 Allow → 直接 POST(免 RO 探查),体为 {sessionId,decision,token},带 Origin,背景任务成对") func decisionSingleHostPosts() async throws { let host = try Self.makeHost(base: Self.hostABase) let harness = Self.makeHarness(hosts: [host]) await harness.http.queueSuccess( method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204 ) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle( .decision(.allow, sessionId: sessionId, token: Self.token) ) let requests = await harness.http.recordedRequests #expect(requests.count == 1) let request = try #require(requests.first) #expect(request.httpMethod == "POST") #expect(request.value(forHTTPHeaderField: "Origin") == Self.hostABase) let body = try JSONDecoder().decode( [String: String].self, from: try #require(request.httpBody) ) #expect(body == [ "sessionId": Self.sessionId, "decision": "allow", "token": Self.token, ]) #expect(harness.backgroundTasks.begunNames.count == 1) #expect(harness.backgroundTasks.endedTokens.count == 1) #expect(harness.notices.posted.isEmpty) } @Test("Deny → decision 字段为 deny") func decisionDenyPostsDeny() async throws { let host = try Self.makeHost(base: Self.hostABase) let harness = Self.makeHarness(hosts: [host]) await harness.http.queueSuccess( method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204 ) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle( .decision(.deny, sessionId: sessionId, token: Self.token) ) let request = try #require(await harness.http.recordedRequests.first) let body = try JSONDecoder().decode( [String: String].self, from: try #require(request.httpBody) ) #expect(body["decision"] == "deny") } @Test("403(token 过期/已用)→ 本地通知兜底提示进 App;文案不含 token;背景任务成对") func decisionRejectedPostsExpiredNotice() async throws { let host = try Self.makeHost(base: Self.hostABase) let harness = Self.makeHarness(hosts: [host]) await harness.http.queueSuccess( method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 403 ) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle( .decision(.allow, sessionId: sessionId, token: Self.token) ) #expect(harness.notices.posted.count == 1) let notice = try #require(harness.notices.posted.first) #expect(notice.title == PushDecisionCopy.expiredTitle) #expect(notice.body == PushDecisionCopy.expiredBody) // capability token 用后即弃:绝不出现在任何兜底文案里。 #expect(!notice.body.contains(Self.token) && !notice.title.contains(Self.token)) #expect(harness.backgroundTasks.endedTokens.count == 1) } @Test("传输层失败 → 本地通知兜底(失败可见,绝不静默吞);背景任务成对") func decisionTransportFailurePostsFailedNotice() async throws { let host = try Self.makeHost(base: Self.hostABase) let harness = Self.makeHarness(hosts: [host]) await harness.http.queueFailure( method: "POST", url: try Self.decisionURL(base: Self.hostABase), error: URLError(.cannotConnectToHost) ) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle( .decision(.allow, sessionId: sessionId, token: Self.token) ) #expect(harness.notices.posted.count == 1) #expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle) #expect(harness.backgroundTasks.begunNames.count == 1) #expect(harness.backgroundTasks.endedTokens.count == 1) } @Test("多主机 → 先 RO 查 /live-sessions(无 Origin)定位持有会话的主机,再对其 POST") func decisionMultiHostResolvesOwner() async throws { let hostA = try Self.makeHost(base: Self.hostABase) let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") let harness = Self.makeHarness(hosts: [hostA, hostB]) try await Self.queueTwoHostProbe(harness) await harness.http.queueSuccess( method: "POST", url: try Self.decisionURL(base: Self.hostBBase), status: 204 ) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle( .decision(.allow, sessionId: sessionId, token: Self.token) ) let requests = await harness.http.recordedRequests let gets = requests.filter { $0.httpMethod == "GET" } #expect(gets.count == 2) for get in gets { // RO 铁律:探查一律不带 Origin #expect(get.value(forHTTPHeaderField: "Origin") == nil) } let posts = requests.filter { $0.httpMethod == "POST" } #expect(posts.count == 1) #expect(posts.first?.url == (try Self.decisionURL(base: Self.hostBBase))) #expect(harness.notices.posted.isEmpty) } @Test("多主机但会话无处可寻 → 不 POST,本地通知兜底") func decisionUnresolvableHostFailsVisibly() async throws { let hostA = try Self.makeHost(base: Self.hostABase) let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") let harness = Self.makeHarness(hosts: [hostA, hostB]) await harness.http.queueSuccess( method: "GET", url: try Self.liveSessionsURL(base: Self.hostABase), body: Self.liveSessionsBody(containing: nil) ) await harness.http.queueSuccess( method: "GET", url: try Self.liveSessionsURL(base: Self.hostBBase), body: Self.liveSessionsBody(containing: nil) ) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle( .decision(.allow, sessionId: sessionId, token: Self.token) ) let posts = await harness.http.recordedRequests.filter { $0.httpMethod == "POST" } #expect(posts.isEmpty) #expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle) } // MARK: - handle(.openSession):默认点按路由 @Test("单主机点按 → openSession(host, id),不发决策 POST,也不开背景任务") func openSessionSingleHostRoutes() async throws { let host = try Self.makeHost(base: Self.hostABase) let harness = Self.makeHarness(hosts: [host]) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle(.openSession(sessionId: sessionId)) #expect(harness.recorder.opened.count == 1) #expect(harness.recorder.opened.first?.host == host) #expect(harness.recorder.opened.first?.sessionId == sessionId) #expect(await harness.http.recordedRequests.isEmpty) #expect(harness.backgroundTasks.begunNames.isEmpty) } @Test("多主机点按 → RO 定位后打开持有主机") func openSessionMultiHostResolves() async throws { let hostA = try Self.makeHost(base: Self.hostABase) let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini") let harness = Self.makeHarness(hosts: [hostA, hostB]) try await Self.queueTwoHostProbe(harness) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle(.openSession(sessionId: sessionId)) #expect(harness.recorder.opened.first?.host == hostB) } @Test("无主机可配 → 不打开、不 crash(App 已被点按拉起,落在列表页即可)") func openSessionUnresolvedIsSafeNoOp() async throws { let harness = Self.makeHarness(hosts: []) let sessionId = try #require(UUID(uuidString: Self.sessionId)) await harness.handler.handle(.openSession(sessionId: sessionId)) #expect(harness.recorder.opened.isEmpty) } // MARK: - 其余分支 @Test(".invalidPayload → 计数 +1,无网络、无通知、无路由") func invalidPayloadCountedOnly() async throws { let harness = Self.makeHarness(hosts: []) await harness.handler.handle(.invalidPayload) #expect(harness.handler.invalidPayloadCount == 1) #expect(await harness.http.recordedRequests.isEmpty) #expect(harness.notices.posted.isEmpty) #expect(harness.recorder.opened.isEmpty) } @Test(".dismissed → 完全 no-op") func dismissedIsNoOp() async throws { let harness = Self.makeHarness(hosts: []) await harness.handler.handle(.dismissed) #expect(harness.handler.invalidPayloadCount == 0) #expect(await harness.http.recordedRequests.isEmpty) } }