import APIClient import Foundation import HostRegistry import Testing import WireProtocol @testable import WebTerm /// C1 · 移除主机 — the path `PushRegistrar.handleHostRemoved` never had. /// /// Two properties matter beyond "the row disappears": /// 1. the APNs de-registration actually happens, and happens BEFORE the record /// is deleted (the request needs the host's endpoint AND its access token, /// which live in that record); /// 2. no secret survives: the token is a field of the removed record, so the /// same write that drops the host drops the token. @MainActor @Suite("Host removal") struct HostRemovalTests { private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345" /// Records the interleaving of the push hook and the store write. private actor Journal { enum Entry: Equatable { case unregistered(UUID) case removed(UUID) } private(set) var entries: [Entry] = [] func record(_ entry: Entry) { entries = entries + [entry] } } /// `InMemoryHostStore` + journalling of `remove`, so ordering is observable. private actor JournallingStore: HostStore { private let base = InMemoryHostStore() private let journal: Journal private let removeFails: Bool init(journal: Journal, removeFails: Bool = false) { self.journal = journal self.removeFails = removeFails } func loadAll() async throws -> [HostRegistry.Host] { try await base.loadAll() } func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { try await base.upsert(host) } func remove(id: UUID) async throws -> [HostRegistry.Host] { await journal.record(.removed(id)) if removeFails { throw StoreFailure() } return try await base.remove(id: id) } func accessToken(host id: UUID) async throws -> AccessToken? { try await base.accessToken(host: id) } func setAccessToken( _ token: AccessToken?, host id: UUID ) async throws -> [HostRegistry.Host] { try await base.setAccessToken(token, host: id) } } private struct StoreFailure: Error {} private actor ThrowingHostStore: HostStore { func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() } func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { throw StoreFailure() } func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() } } private func makeViewModel(store: any HostStore, journal: Journal) -> PairingViewModel { PairingViewModel( store: store, probe: PairingViewModel.Probe( verifyHost: { endpoint, _ in .success(endpoint) }, unregisterPush: { host in await journal.record(.unregistered(host.id)) } ) ) } private func makeHost( name: String, port: Int, token: String? = nil ) throws -> HostRegistry.Host { let url = try #require(URL(string: "http://192.168.1.5:\(port)")) return HostRegistry.Host( id: UUID(), name: name, endpoint: try #require(HostEndpoint(baseURL: url)), accessToken: try token.map { try AccessToken(validating: $0) } ) } // MARK: - The wiring @Test("移除主机:先注销该主机上的推送 token,再删记录(顺序是硬要求)") func removalDeregistersPushBeforeDeletingTheRecord() async throws { let journal = Journal() let store = JournallingStore(journal: journal) let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken) _ = try await store.upsert(host) let viewModel = makeViewModel(store: store, journal: journal) await viewModel.loadPairedHosts() await viewModel.removeHost(id: host.id) #expect(await journal.entries == [.unregistered(host.id), .removed(host.id)]) #expect(viewModel.pairedHosts.isEmpty) #expect(try await store.loadAll().isEmpty) } @Test("移除主机后本机不留令牌(令牌是记录的一个字段,同一次写入一起消失)") func removalLeavesNoTokenBehind() async throws { let journal = Journal() let store = JournallingStore(journal: journal) let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken) _ = try await store.upsert(host) #expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken) let viewModel = makeViewModel(store: store, journal: journal) await viewModel.loadPairedHosts() await viewModel.removeHost(id: host.id) #expect(try await store.accessToken(host: host.id) == nil) #expect(try await store.loadAll().allSatisfy { $0.accessToken == nil }) } @Test("只移除指定主机,其它主机及其令牌不受影响") func removalIsScopedToOneHost() async throws { let journal = Journal() let store = JournallingStore(journal: journal) let doomed = try makeHost(name: "旧 Mac", port: 3000, token: Self.goodToken) let kept = try makeHost(name: "新 Mac", port: 3100, token: Self.goodToken) _ = try await store.upsert(doomed) _ = try await store.upsert(kept) let viewModel = makeViewModel(store: store, journal: journal) await viewModel.loadPairedHosts() await viewModel.removeHost(id: doomed.id) #expect(viewModel.pairedHosts.map(\.id) == [kept.id]) #expect(try await store.accessToken(host: kept.id)?.rawValue == Self.goodToken) #expect(await journal.entries == [.unregistered(doomed.id), .removed(doomed.id)]) } @Test("未知 id → 完全 no-op(不注销、不写存储)") func unknownIdIsANoOp() async throws { let journal = Journal() let store = JournallingStore(journal: journal) let host = try makeHost(name: "书房 Mac", port: 3000) _ = try await store.upsert(host) let viewModel = makeViewModel(store: store, journal: journal) await viewModel.loadPairedHosts() await viewModel.removeHost(id: UUID()) #expect(await journal.entries.isEmpty) #expect(try await store.loadAll().count == 1) } @Test("存储写入失败 → 显式报错(不假装移除成功)") func storeFailureIsSurfaced() async throws { let journal = Journal() let store = JournallingStore(journal: journal, removeFails: true) let host = try makeHost(name: "书房 Mac", port: 3000) _ = try await store.upsert(host) let viewModel = makeViewModel(store: store, journal: journal) await viewModel.loadPairedHosts() await viewModel.removeHost(id: host.id) #expect(viewModel.hostsErrorMessage == PairingCopy.hostRemoveFailed) #expect(viewModel.pairedHosts.map(\.id) == [host.id]) // list unchanged } // MARK: - Manage-section loading @Test("加载已配对主机:成功清错,失败给出显式话术") func loadingPairedHostsSurfacesErrors() async throws { let journal = Journal() let failing = makeViewModel(store: ThrowingHostStore(), journal: journal) await failing.loadPairedHosts() #expect(failing.pairedHosts.isEmpty) #expect(failing.hostsErrorMessage == PairingCopy.hostsLoadFailed) let working = makeViewModel(store: InMemoryHostStore(), journal: journal) await working.loadPairedHosts() #expect(working.hostsErrorMessage == nil) } }