Test ProfileManager (#901)
With some housekeeping. Bugfixing: - Do NOT skip empty remote profiles, allow removal when mirroring - Look up profile in all profiles, not just filtered - Posptone non-included profile removal Refactoring: - Rename ProfileProcessor to InAppProcessor - Provides ProfileProcessor + TunnelProcessor protocols - willSave -> willRebuild (because not always called on save) - Notify ProfileManager import events
This commit is contained in:
parent
c7cef70eed
commit
a93fcd4c66
|
@ -33,7 +33,7 @@ public final class ExtendedTunnel: ObservableObject {
|
|||
|
||||
private let environment: TunnelEnvironment
|
||||
|
||||
private let processor: ProfileProcessor?
|
||||
private let processor: TunnelProcessor?
|
||||
|
||||
private let interval: TimeInterval
|
||||
|
||||
|
@ -56,7 +56,7 @@ public final class ExtendedTunnel: ObservableObject {
|
|||
public init(
|
||||
tunnel: Tunnel,
|
||||
environment: TunnelEnvironment,
|
||||
processor: ProfileProcessor? = nil,
|
||||
processor: TunnelProcessor? = nil,
|
||||
interval: TimeInterval
|
||||
) {
|
||||
self.tunnel = tunnel
|
||||
|
@ -196,7 +196,7 @@ private extension ExtendedTunnel {
|
|||
|
||||
func processedProfile(_ profile: Profile) throws -> Profile {
|
||||
if let processor {
|
||||
return try processor.willConnect(profile)
|
||||
return try processor.willConnect(to: profile)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
|
|
@ -39,6 +39,10 @@ public final class ProfileManager: ObservableObject {
|
|||
case save(Profile)
|
||||
|
||||
case remove([Profile.ID])
|
||||
|
||||
case startRemoteImport
|
||||
|
||||
case stopRemoteImport
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
@ -91,28 +95,13 @@ public final class ProfileManager: ObservableObject {
|
|||
private var remoteImportTask: Task<Void, Never>?
|
||||
|
||||
// for testing/previews
|
||||
public init(profiles: [Profile]) {
|
||||
repository = InMemoryProfileRepository(profiles: profiles)
|
||||
backupRepository = nil
|
||||
remoteRepositoryBlock = { _ in
|
||||
public convenience init(profiles: [Profile]) {
|
||||
self.init(
|
||||
repository: InMemoryProfileRepository(profiles: profiles),
|
||||
remoteRepositoryBlock: { _ in
|
||||
InMemoryProfileRepository()
|
||||
}
|
||||
mirrorsRemoteRepository = false
|
||||
processor = nil
|
||||
|
||||
allProfiles = profiles.reduce(into: [:]) {
|
||||
$0[$1.id] = $1
|
||||
}
|
||||
allRemoteProfiles = [:]
|
||||
filteredProfiles = []
|
||||
requiredFeatures = [:]
|
||||
isRemoteImportingEnabled = false
|
||||
waitingObservers = []
|
||||
|
||||
didChange = PassthroughSubject()
|
||||
searchSubject = CurrentValueSubject("")
|
||||
|
||||
observeSearch()
|
||||
)
|
||||
}
|
||||
|
||||
public init(
|
||||
|
@ -158,10 +147,6 @@ extension ProfileManager {
|
|||
!filteredProfiles.isEmpty
|
||||
}
|
||||
|
||||
public var isSearching: Bool {
|
||||
!searchSubject.value.isEmpty
|
||||
}
|
||||
|
||||
public var headers: [ProfileHeader] {
|
||||
filteredProfiles.map {
|
||||
$0.header()
|
||||
|
@ -169,19 +154,21 @@ extension ProfileManager {
|
|||
}
|
||||
|
||||
public func profile(withId profileId: Profile.ID) -> Profile? {
|
||||
filteredProfiles.first {
|
||||
$0.id == profileId
|
||||
}
|
||||
allProfiles[profileId]
|
||||
}
|
||||
|
||||
public func requiredFeatures(forProfileWithId profileId: Profile.ID) -> Set<AppFeature>? {
|
||||
requiredFeatures[profileId]
|
||||
public var isSearching: Bool {
|
||||
!searchSubject.value.isEmpty
|
||||
}
|
||||
|
||||
public func search(byName name: String) {
|
||||
searchSubject.send(name)
|
||||
}
|
||||
|
||||
public func requiredFeatures(forProfileWithId profileId: Profile.ID) -> Set<AppFeature>? {
|
||||
requiredFeatures[profileId]
|
||||
}
|
||||
|
||||
public func reloadRequiredFeatures() {
|
||||
guard let processor else {
|
||||
return
|
||||
|
@ -196,7 +183,7 @@ extension ProfileManager {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - CRUD
|
||||
// MARK: - Edit
|
||||
|
||||
extension ProfileManager {
|
||||
public func save(_ originalProfile: Profile, force: Bool = false, remotelyShared: Bool? = nil) async throws {
|
||||
|
@ -204,7 +191,7 @@ extension ProfileManager {
|
|||
if force {
|
||||
var builder = originalProfile.builder()
|
||||
if let processor {
|
||||
builder = try processor.willSave(builder)
|
||||
builder = try processor.willRebuild(builder)
|
||||
}
|
||||
builder.attributes.lastUpdate = Date()
|
||||
builder.attributes.fingerprint = UUID()
|
||||
|
@ -262,10 +249,6 @@ extension ProfileManager {
|
|||
pp_log(.App.profiles, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func exists(withId profileId: Profile.ID) -> Bool {
|
||||
allProfiles.keys.contains(profileId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote/Attributes
|
||||
|
@ -384,15 +367,36 @@ private extension ProfileManager {
|
|||
private extension ProfileManager {
|
||||
func reloadLocalProfiles(_ result: [Profile]) {
|
||||
pp_log(.App.profiles, .info, "Reload local profiles: \(result.map(\.id))")
|
||||
allProfiles = result.reduce(into: [:]) {
|
||||
|
||||
let excludedIds = Set(result
|
||||
.filter {
|
||||
!(processor?.isIncluded($0) ?? true)
|
||||
}
|
||||
.map(\.id))
|
||||
|
||||
allProfiles = result
|
||||
.filter {
|
||||
!excludedIds.contains($0.id)
|
||||
}
|
||||
.reduce(into: [:]) {
|
||||
$0[$1.id] = $1
|
||||
}
|
||||
|
||||
pp_log(.App.profiles, .info, "Local profiles after exclusions: \(allProfiles.keys)")
|
||||
|
||||
if waitingObservers.contains(.local) {
|
||||
waitingObservers.remove(.local) // @Published
|
||||
waitingObservers.remove(.local)
|
||||
}
|
||||
deleteExcludedProfiles()
|
||||
|
||||
objectWillChange.send()
|
||||
|
||||
if !excludedIds.isEmpty {
|
||||
pp_log(.App.profiles, .info, "Delete excluded profiles from repository: \(excludedIds)")
|
||||
Task {
|
||||
// FIXME: ###, ignore this published value
|
||||
try await repository.removeProfiles(withIds: Array(excludedIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadRemoteProfiles(_ result: [Profile]) {
|
||||
|
@ -401,39 +405,18 @@ private extension ProfileManager {
|
|||
$0[$1.id] = $1
|
||||
}
|
||||
if waitingObservers.contains(.remote) {
|
||||
waitingObservers.remove(.remote) // @Published
|
||||
waitingObservers.remove(.remote)
|
||||
}
|
||||
Task { [weak self] in
|
||||
self?.didChange.send(.startRemoteImport)
|
||||
await self?.importRemoteProfiles(result)
|
||||
self?.didChange.send(.stopRemoteImport)
|
||||
}
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
// should not be imported at all, but you never know
|
||||
func deleteExcludedProfiles() {
|
||||
guard let processor else {
|
||||
return
|
||||
}
|
||||
let idsToRemove: [Profile.ID] = allProfiles
|
||||
.filter {
|
||||
!processor.isIncluded($0.value)
|
||||
}
|
||||
.map(\.key)
|
||||
|
||||
if !idsToRemove.isEmpty {
|
||||
pp_log(.App.profiles, .info, "Delete non-included local profiles: \(idsToRemove)")
|
||||
Task.detached {
|
||||
try await self.repository.removeProfiles(withIds: idsToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importRemoteProfiles(_ profiles: [Profile]) async {
|
||||
guard !profiles.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if let previousTask = remoteImportTask {
|
||||
pp_log(.App.profiles, .info, "Cancel ongoing remote import...")
|
||||
previousTask.cancel()
|
||||
|
@ -496,11 +479,13 @@ private extension ProfileManager {
|
|||
}
|
||||
|
||||
pp_log(.App.profiles, .notice, "Finished importing remote profiles, delete stale profiles: \(idsToRemove)")
|
||||
if !idsToRemove.isEmpty {
|
||||
do {
|
||||
try await repository.removeProfiles(withIds: idsToRemove)
|
||||
} catch {
|
||||
pp_log(.App.profiles, .error, "Unable to delete stale profiles: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// yield a little bit
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ProfileProcessor.swift
|
||||
// InAppProcessor.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/6/24.
|
||||
|
@ -26,15 +26,14 @@
|
|||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
@MainActor
|
||||
public final class ProfileProcessor: ObservableObject, Sendable {
|
||||
public final class InAppProcessor: ObservableObject, Sendable {
|
||||
private let iapManager: IAPManager
|
||||
|
||||
public nonisolated let title: (Profile) -> String
|
||||
public nonisolated let _title: (Profile) -> String
|
||||
|
||||
private nonisolated let _isIncluded: (IAPManager, Profile) -> Bool
|
||||
|
||||
private nonisolated let _willSave: (IAPManager, Profile.Builder) throws -> Profile.Builder
|
||||
private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder
|
||||
|
||||
private nonisolated let _willConnect: (IAPManager, Profile) throws -> Profile
|
||||
|
||||
|
@ -44,31 +43,43 @@ public final class ProfileProcessor: ObservableObject, Sendable {
|
|||
iapManager: IAPManager,
|
||||
title: @escaping (Profile) -> String,
|
||||
isIncluded: @escaping (IAPManager, Profile) -> Bool,
|
||||
willSave: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder,
|
||||
willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder,
|
||||
willConnect: @escaping (IAPManager, Profile) throws -> Profile,
|
||||
verify: @escaping (IAPManager, Profile) -> Set<AppFeature>?
|
||||
) {
|
||||
self.iapManager = iapManager
|
||||
self.title = title
|
||||
_title = title
|
||||
_isIncluded = isIncluded
|
||||
_willSave = willSave
|
||||
_willRebuild = willRebuild
|
||||
_willConnect = willConnect
|
||||
_verify = verify
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProfileProcessor
|
||||
|
||||
extension InAppProcessor: ProfileProcessor {
|
||||
public func title(for profile: Profile) -> String {
|
||||
_title(profile)
|
||||
}
|
||||
|
||||
public func isIncluded(_ profile: Profile) -> Bool {
|
||||
_isIncluded(iapManager, profile)
|
||||
}
|
||||
|
||||
public func willSave(_ builder: Profile.Builder) throws -> Profile.Builder {
|
||||
try _willSave(iapManager, builder)
|
||||
}
|
||||
|
||||
public func willConnect(_ profile: Profile) throws -> Profile {
|
||||
try _willConnect(iapManager, profile)
|
||||
public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {
|
||||
try _willRebuild(iapManager, builder)
|
||||
}
|
||||
|
||||
public func verify(_ profile: Profile) -> Set<AppFeature>? {
|
||||
_verify(iapManager, profile)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TunnelProcessor
|
||||
|
||||
extension InAppProcessor: TunnelProcessor {
|
||||
public func willConnect(to profile: Profile) throws -> Profile {
|
||||
try _willConnect(iapManager, profile)
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@ import Foundation
|
|||
import PassepartoutKit
|
||||
|
||||
public final class InMemoryProfileRepository: ProfileRepository {
|
||||
private var profiles: [Profile] {
|
||||
var profiles: [Profile] {
|
||||
didSet {
|
||||
profilesSubject.send(profiles)
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ public final class InMemoryProfileRepository: ProfileRepository {
|
|||
}
|
||||
|
||||
public func saveProfile(_ profile: Profile) {
|
||||
pp_log(.App.profiles, .info, "Save profile: \(profile.id))")
|
||||
pp_log(.App.profiles, .info, "Save profile: \(profile.id)")
|
||||
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
|
||||
profiles[index] = profile
|
||||
} else {
|
||||
|
@ -60,9 +60,13 @@ public final class InMemoryProfileRepository: ProfileRepository {
|
|||
|
||||
public func removeProfiles(withIds ids: [Profile.ID]) {
|
||||
pp_log(.App.profiles, .info, "Remove profiles: \(ids)")
|
||||
profiles = profiles.filter {
|
||||
let newProfiles = profiles.filter {
|
||||
!ids.contains($0.id)
|
||||
}
|
||||
guard newProfiles.count < profiles.count else {
|
||||
return
|
||||
}
|
||||
profiles = newProfiles
|
||||
}
|
||||
|
||||
public func removeAllProfiles() async throws {
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// ProfileProcessor.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/20/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
//
|
||||
// This file is part of Passepartout.
|
||||
//
|
||||
// Passepartout is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Passepartout is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
public protocol ProfileProcessor {
|
||||
func isIncluded(_ profile: Profile) -> Bool
|
||||
|
||||
func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder
|
||||
|
||||
func verify(_ profile: Profile) -> Set<AppFeature>?
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// TunnelProcessor.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/20/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
//
|
||||
// This file is part of Passepartout.
|
||||
//
|
||||
// Passepartout is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Passepartout is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
public protocol TunnelProcessor {
|
||||
func title(for profile: Profile) -> String
|
||||
|
||||
func willConnect(to profile: Profile) throws -> Profile
|
||||
}
|
|
@ -44,7 +44,7 @@ extension AppContext {
|
|||
[]
|
||||
}
|
||||
)
|
||||
let processor = ProfileProcessor(
|
||||
let processor = InAppProcessor(
|
||||
iapManager: iapManager,
|
||||
title: {
|
||||
"Passepartout.Mock: \($0.name)"
|
||||
|
@ -52,7 +52,7 @@ extension AppContext {
|
|||
isIncluded: { _, _ in
|
||||
true
|
||||
},
|
||||
willSave: { _, builder in
|
||||
willRebuild: { _, builder in
|
||||
builder
|
||||
},
|
||||
willConnect: { _, profile in
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// MockProfileRepository.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/20/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
//
|
||||
// This file is part of Passepartout.
|
||||
//
|
||||
// Passepartout is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Passepartout is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
final class MockProfileProcessor: ProfileProcessor {
|
||||
var isIncludedCount = 0
|
||||
|
||||
var willRebuildCount = 0
|
||||
|
||||
var verifyCount = 0
|
||||
|
||||
var isIncludedBlock: (Profile) -> Bool = { _ in true }
|
||||
|
||||
var requiredFeatures: Set<AppFeature>?
|
||||
|
||||
func title(for profile: Profile) -> String {
|
||||
profile.name
|
||||
}
|
||||
|
||||
func isIncluded(_ profile: Profile) -> Bool {
|
||||
isIncludedCount += 1
|
||||
return isIncludedBlock(profile)
|
||||
}
|
||||
|
||||
func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {
|
||||
willRebuildCount += 1
|
||||
return builder
|
||||
}
|
||||
|
||||
func verify(_ profile: Profile) -> Set<AppFeature>? {
|
||||
verifyCount += 1
|
||||
return requiredFeatures
|
||||
}
|
||||
}
|
|
@ -23,12 +23,706 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Combine
|
||||
@testable import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class ProfileManagerTests: XCTestCase {
|
||||
func test_given_when_then() async {
|
||||
// let sut = await ProfileManager(profiles: [])
|
||||
private let timeout = 1.0
|
||||
|
||||
private var subscriptions: Set<AnyCancellable> = []
|
||||
}
|
||||
|
||||
extension ProfileManagerTests {
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
extension ProfileManagerTests {
|
||||
func test_givenStatic_whenNotReady_thenHasProfiles() {
|
||||
let profile = newProfile()
|
||||
let sut = ProfileManager(profiles: [profile])
|
||||
XCTAssertFalse(sut.isReady)
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
XCTAssertTrue(sut.headers.isEmpty)
|
||||
}
|
||||
|
||||
func test_givenRepository_whenNotReady_thenHasNoProfiles() {
|
||||
let repository = InMemoryProfileRepository(profiles: [])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
XCTAssertFalse(sut.isReady)
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
XCTAssertTrue(sut.headers.isEmpty)
|
||||
}
|
||||
|
||||
func test_givenRepository_whenReady_thenHasProfiles() async throws {
|
||||
let profile = newProfile()
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertTrue(sut.hasProfiles)
|
||||
XCTAssertEqual(sut.headers.count, 1)
|
||||
XCTAssertEqual(sut.profile(withId: profile.id), profile)
|
||||
}
|
||||
|
||||
func test_givenRepository_whenSearch_thenIsSearching() async throws {
|
||||
let profile1 = newProfile("foo")
|
||||
let profile2 = newProfile("bar")
|
||||
let repository = InMemoryProfileRepository(profiles: [profile1, profile2])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertTrue(sut.hasProfiles)
|
||||
XCTAssertEqual(sut.headers.count, 2)
|
||||
|
||||
try await wait(sut) {
|
||||
$0.search(byName: "ar")
|
||||
} until: {
|
||||
$0.headers.count == 1
|
||||
}
|
||||
XCTAssertTrue(sut.isSearching)
|
||||
let found = try XCTUnwrap(sut.headers.last)
|
||||
XCTAssertEqual(found.id, profile2.id)
|
||||
}
|
||||
|
||||
func test_givenRepositoryAndProcessor_whenReady_thenHasInvokedProcessor() async throws {
|
||||
let profile = newProfile()
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let processor = MockProfileProcessor()
|
||||
processor.requiredFeatures = [.appleTV, .onDemand]
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
|
||||
XCTAssertEqual(processor.isIncludedCount, 1)
|
||||
XCTAssertEqual(processor.willRebuildCount, 0)
|
||||
XCTAssertEqual(processor.verifyCount, 1)
|
||||
XCTAssertEqual(sut.requiredFeatures(forProfileWithId: profile.id), processor.requiredFeatures)
|
||||
}
|
||||
|
||||
func test_givenRepositoryAndProcessor_whenIncludedProfiles_thenLoadsIncluded() async throws {
|
||||
let localProfiles = [
|
||||
newProfile("local1"),
|
||||
newProfile("local2"),
|
||||
newProfile("local3")
|
||||
]
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let processor = MockProfileProcessor()
|
||||
processor.isIncludedBlock = {
|
||||
$0.name == "local2"
|
||||
}
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
|
||||
XCTAssertEqual(sut.headers.count, 1)
|
||||
XCTAssertEqual(sut.headers.first?.name, "local2")
|
||||
}
|
||||
|
||||
func test_givenRepositoryAndProcessor_whenRequiredFeaturesChange_thenMustReload() async throws {
|
||||
let profile = newProfile()
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let processor = MockProfileProcessor()
|
||||
processor.requiredFeatures = [.appleTV, .onDemand]
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
|
||||
XCTAssertEqual(sut.requiredFeatures(forProfileWithId: profile.id), processor.requiredFeatures)
|
||||
processor.requiredFeatures = [.interactiveLogin]
|
||||
XCTAssertNotEqual(sut.requiredFeatures(forProfileWithId: profile.id), processor.requiredFeatures)
|
||||
sut.reloadRequiredFeatures()
|
||||
XCTAssertEqual(sut.requiredFeatures(forProfileWithId: profile.id), processor.requiredFeatures)
|
||||
|
||||
processor.requiredFeatures = nil
|
||||
XCTAssertNotNil(sut.requiredFeatures(forProfileWithId: profile.id))
|
||||
sut.reloadRequiredFeatures()
|
||||
XCTAssertNil(sut.requiredFeatures(forProfileWithId: profile.id))
|
||||
processor.requiredFeatures = []
|
||||
sut.reloadRequiredFeatures()
|
||||
XCTAssertNil(sut.requiredFeatures(forProfileWithId: profile.id))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit
|
||||
|
||||
extension ProfileManagerTests {
|
||||
func test_givenRepository_whenSave_thenIsSaved() async throws {
|
||||
let repository = InMemoryProfileRepository(profiles: [])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
|
||||
let profile = newProfile()
|
||||
try await wait(sut) {
|
||||
try await $0.save(profile)
|
||||
} until: {
|
||||
$0.hasProfiles
|
||||
}
|
||||
XCTAssertEqual(sut.headers.count, 1)
|
||||
XCTAssertEqual(sut.profile(withId: profile.id), profile)
|
||||
}
|
||||
|
||||
func test_givenRepository_whenSaveExisting_thenIsReplaced() async throws {
|
||||
let profile = newProfile("oldName")
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertEqual(sut.headers.first?.id, profile.id)
|
||||
|
||||
var builder = profile.builder()
|
||||
builder.name = "newName"
|
||||
let renamedProfile = try builder.tryBuild()
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.save(renamedProfile)
|
||||
} until: {
|
||||
$0.headers.first?.name == renamedProfile.name
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenRepositoryAndProcessor_whenSave_thenProcessorIsNotInvoked() async throws {
|
||||
let repository = InMemoryProfileRepository(profiles: [])
|
||||
let processor = MockProfileProcessor()
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
|
||||
let profile = newProfile()
|
||||
try await sut.save(profile)
|
||||
XCTAssertEqual(processor.willRebuildCount, 0)
|
||||
try await sut.save(profile, force: false)
|
||||
XCTAssertEqual(processor.willRebuildCount, 0)
|
||||
}
|
||||
|
||||
func test_givenRepositoryAndProcessor_whenSaveForce_thenProcessorIsInvoked() async throws {
|
||||
let repository = InMemoryProfileRepository(profiles: [])
|
||||
let processor = MockProfileProcessor()
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil, processor: processor)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
|
||||
let profile = newProfile()
|
||||
try await sut.save(profile, force: true)
|
||||
XCTAssertEqual(processor.willRebuildCount, 1)
|
||||
}
|
||||
|
||||
func test_givenRepository_whenSave_thenIsStoredToBackUpRepository() async throws {
|
||||
let repository = InMemoryProfileRepository(profiles: [])
|
||||
let backupRepository = InMemoryProfileRepository(profiles: [])
|
||||
let sut = ProfileManager(repository: repository, backupRepository: backupRepository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
|
||||
let profile = newProfile()
|
||||
let exp = expectation(description: "Backup")
|
||||
backupRepository
|
||||
.profilesPublisher
|
||||
.sink {
|
||||
guard !$0.isEmpty else {
|
||||
return
|
||||
}
|
||||
XCTAssertEqual($0.first, profile)
|
||||
exp.fulfill()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
try await sut.save(profile)
|
||||
await fulfillment(of: [exp], timeout: timeout)
|
||||
}
|
||||
|
||||
func test_givenRepository_whenRemove_thenIsRemoved() async throws {
|
||||
let profile = newProfile()
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertTrue(sut.isReady)
|
||||
XCTAssertTrue(sut.hasProfiles)
|
||||
|
||||
try await wait(sut) {
|
||||
await $0.remove(withId: profile.id)
|
||||
} until: {
|
||||
!$0.hasProfiles
|
||||
}
|
||||
XCTAssertTrue(sut.headers.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote/Attributes
|
||||
|
||||
extension ProfileManagerTests {
|
||||
func test_givenRemoteRepository_whenSaveRemotelyShared_thenIsStoredToRemoteRepository() async throws {
|
||||
let profile = newProfile()
|
||||
let repository = InMemoryProfileRepository()
|
||||
let remoteRepository = InMemoryProfileRepository()
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
})
|
||||
|
||||
try await waitForReady(sut)
|
||||
|
||||
let exp = expectation(description: "Remote")
|
||||
remoteRepository
|
||||
.profilesPublisher
|
||||
.sink {
|
||||
guard !$0.isEmpty else {
|
||||
return
|
||||
}
|
||||
XCTAssertEqual($0.first, profile)
|
||||
exp.fulfill()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
try await sut.save(profile, remotelyShared: true)
|
||||
await fulfillment(of: [exp], timeout: timeout)
|
||||
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: profile.id))
|
||||
}
|
||||
|
||||
func test_givenRemoteRepository_whenSaveNotRemotelyShared_thenIsRemoveFromRemoteRepository() async throws {
|
||||
let profile = newProfile()
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let remoteRepository = InMemoryProfileRepository(profiles: [profile])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
})
|
||||
|
||||
try await waitForReady(sut)
|
||||
|
||||
let exp = expectation(description: "Remote")
|
||||
remoteRepository
|
||||
.profilesPublisher
|
||||
.sink {
|
||||
guard $0.isEmpty else {
|
||||
return
|
||||
}
|
||||
exp.fulfill()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
try await sut.save(profile, remotelyShared: false)
|
||||
await fulfillment(of: [exp], timeout: timeout)
|
||||
|
||||
XCTAssertFalse(sut.isRemotelyShared(profileWithId: profile.id))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shortcuts
|
||||
|
||||
extension ProfileManagerTests {
|
||||
func test_givenRepository_whenNew_thenReturnsProfileWithNewName() async throws {
|
||||
let profile = newProfile("example")
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
XCTAssertEqual(sut.headers.count, 1)
|
||||
|
||||
let newProfile = sut.new(withName: profile.name)
|
||||
XCTAssertEqual(newProfile.name, "example.1")
|
||||
}
|
||||
|
||||
func test_givenRepository_whenDuplicate_thenSavesProfileWithNewName() async throws {
|
||||
let profile = newProfile("example")
|
||||
let repository = InMemoryProfileRepository(profiles: [profile])
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
|
||||
|
||||
try await waitForReady(sut)
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.duplicate(profileWithId: profile.id)
|
||||
} until: {
|
||||
$0.headers.count == 2
|
||||
}
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.duplicate(profileWithId: profile.id)
|
||||
} until: {
|
||||
$0.headers.count == 3
|
||||
}
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.duplicate(profileWithId: profile.id)
|
||||
} until: {
|
||||
$0.headers.count == 4
|
||||
}
|
||||
|
||||
XCTAssertEqual(sut.headers.map(\.name), [
|
||||
"example",
|
||||
"example.1",
|
||||
"example.2",
|
||||
"example.3"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observation
|
||||
|
||||
extension ProfileManagerTests {
|
||||
func test_givenRemoteRepository_whenUpdatesWithNewProfiles_thenImportsAll() async throws {
|
||||
let localProfiles = [
|
||||
newProfile("local1"),
|
||||
newProfile("local2")
|
||||
]
|
||||
let remoteProfiles = [
|
||||
newProfile("remote1"),
|
||||
newProfile("remote2"),
|
||||
newProfile("remote3")
|
||||
]
|
||||
let allProfiles = localProfiles + remoteProfiles
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
})
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.observeLocal()
|
||||
try await $0.observeRemote(true)
|
||||
} until: {
|
||||
$0.headers.count == allProfiles.count
|
||||
}
|
||||
|
||||
XCTAssertEqual(Set(sut.headers), Set(allProfiles.map { $0.header() }))
|
||||
localProfiles.forEach {
|
||||
XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
}
|
||||
remoteProfiles.forEach {
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenRemoteRepository_whenUpdatesWithExistingProfiles_thenReplacesLocal() async throws {
|
||||
let l1 = UUID()
|
||||
let l2 = UUID()
|
||||
let l3 = UUID()
|
||||
let r3 = UUID()
|
||||
let localProfiles = [
|
||||
newProfile("local1", id: l1),
|
||||
newProfile("local2", id: l2),
|
||||
newProfile("local3", id: l3)
|
||||
]
|
||||
let remoteProfiles = [
|
||||
newProfile("remote1", id: l1),
|
||||
newProfile("remote2", id: l2),
|
||||
newProfile("remote3", id: r3)
|
||||
]
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
})
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.observeLocal()
|
||||
try await $0.observeRemote(true)
|
||||
} until: {
|
||||
$0.headers.count == 4 // unique IDs
|
||||
}
|
||||
|
||||
sut.headers.forEach {
|
||||
switch $0.id {
|
||||
case l1:
|
||||
XCTAssertEqual($0.name, "remote1")
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
case l2:
|
||||
XCTAssertEqual($0.name, "remote2")
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
case l3:
|
||||
XCTAssertEqual($0.name, "local3")
|
||||
XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
case r3:
|
||||
XCTAssertEqual($0.name, "remote3")
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
default:
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenRemoteRepository_whenUpdatesWithNotIncludedProfiles_thenImportsNone() async throws {
|
||||
let localProfiles = [
|
||||
newProfile("local1"),
|
||||
newProfile("local2")
|
||||
]
|
||||
let remoteProfiles = [
|
||||
newProfile("remote1"),
|
||||
newProfile("remote2"),
|
||||
newProfile("remote3")
|
||||
]
|
||||
let allProfiles = localProfiles + remoteProfiles
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
|
||||
let processor = MockProfileProcessor()
|
||||
processor.isIncludedBlock = {
|
||||
!$0.name.hasPrefix("remote")
|
||||
}
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
}, processor: processor)
|
||||
|
||||
var didImport = false
|
||||
observeRemoteImport(sut) {
|
||||
didImport = true
|
||||
}
|
||||
try await waitForReady(sut)
|
||||
try await wait(sut) { _ in
|
||||
//
|
||||
} until: { _ in
|
||||
didImport
|
||||
}
|
||||
|
||||
XCTAssertEqual(processor.isIncludedCount, allProfiles.count)
|
||||
XCTAssertEqual(Set(sut.headers), Set(localProfiles.map { $0.header() }))
|
||||
localProfiles.forEach {
|
||||
XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
}
|
||||
remoteProfiles.forEach {
|
||||
XCTAssertNil(sut.profile(withId: $0.id))
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenRemoteRepository_whenUpdatesWithSameFingerprint_thenDoesNotImport() async throws {
|
||||
let l1 = UUID()
|
||||
let l2 = UUID()
|
||||
let fp1 = UUID()
|
||||
let localProfiles = [
|
||||
newProfile("local1", id: l1, fingerprint: fp1),
|
||||
newProfile("local2", id: l2, fingerprint: UUID())
|
||||
]
|
||||
let remoteProfiles = [
|
||||
newProfile("remote1", id: l1, fingerprint: fp1),
|
||||
newProfile("remote2", id: l2, fingerprint: UUID())
|
||||
]
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
|
||||
let processor = MockProfileProcessor()
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
}, processor: processor)
|
||||
|
||||
var didImport = false
|
||||
observeRemoteImport(sut) {
|
||||
didImport = true
|
||||
}
|
||||
try await waitForReady(sut)
|
||||
try await wait(sut) { _ in
|
||||
//
|
||||
} until: { _ in
|
||||
didImport
|
||||
}
|
||||
|
||||
try sut.headers.forEach {
|
||||
let profile = try XCTUnwrap(sut.profile(withId: $0.id))
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
switch $0.id {
|
||||
case l1:
|
||||
XCTAssertEqual(profile.name, "local1")
|
||||
XCTAssertEqual(profile.attributes.fingerprint, localProfiles[0].attributes.fingerprint)
|
||||
case l2:
|
||||
XCTAssertEqual(profile.name, "remote2")
|
||||
XCTAssertEqual(profile.attributes.fingerprint, remoteProfiles[1].attributes.fingerprint)
|
||||
default:
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenRemoteRepository_whenUpdatesMultipleTimes_thenLatestImportWins() async throws {
|
||||
let localProfiles = [
|
||||
newProfile("local1"),
|
||||
newProfile("local2")
|
||||
]
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository()
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
})
|
||||
|
||||
try await wait(sut) {
|
||||
try await $0.observeLocal()
|
||||
try await $0.observeRemote(true)
|
||||
} until: {
|
||||
$0.headers.count == localProfiles.count
|
||||
}
|
||||
|
||||
let r1 = UUID()
|
||||
let r2 = UUID()
|
||||
let r3 = UUID()
|
||||
let fp1 = UUID()
|
||||
let fp2 = UUID()
|
||||
let fp3 = UUID()
|
||||
|
||||
try await wait(sut) { _ in
|
||||
remoteRepository.profiles = [
|
||||
newProfile("remote1", id: r1)
|
||||
]
|
||||
remoteRepository.profiles = [
|
||||
newProfile("remote1", id: r1),
|
||||
newProfile("remote2", id: r2),
|
||||
]
|
||||
remoteRepository.profiles = [
|
||||
newProfile("remote1", id: r1, fingerprint: fp1),
|
||||
newProfile("remote2", id: r2, fingerprint: fp2),
|
||||
newProfile("remote3", id: r3, fingerprint: fp3)
|
||||
]
|
||||
} until: {
|
||||
$0.headers.count == 5
|
||||
}
|
||||
|
||||
localProfiles.forEach {
|
||||
XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
}
|
||||
remoteRepository.profiles.forEach {
|
||||
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
|
||||
switch $0.id {
|
||||
case r1:
|
||||
XCTAssertEqual($0.attributes.fingerprint, fp1)
|
||||
case r2:
|
||||
XCTAssertEqual($0.attributes.fingerprint, fp2)
|
||||
case r3:
|
||||
XCTAssertEqual($0.attributes.fingerprint, fp3)
|
||||
default:
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenRemoteRepository_whenRemoteIsDeleted_thenLocalIsRetained() async throws {
|
||||
let profile = newProfile()
|
||||
let localProfiles = [profile]
|
||||
let remoteProfiles = [profile]
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository(profiles: remoteProfiles)
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
})
|
||||
|
||||
var didImport = false
|
||||
observeRemoteImport(sut) {
|
||||
didImport = true
|
||||
}
|
||||
try await wait(sut) {
|
||||
try await $0.observeLocal()
|
||||
try await $0.observeRemote(true)
|
||||
} until: {
|
||||
$0.headers.count == 1
|
||||
}
|
||||
try await wait(sut) { _ in
|
||||
remoteRepository.profiles = []
|
||||
} until: { _ in
|
||||
didImport
|
||||
}
|
||||
|
||||
XCTAssertEqual(sut.headers.count, 1)
|
||||
XCTAssertEqual(sut.headers.first, profile.header())
|
||||
}
|
||||
|
||||
func test_givenRemoteRepositoryAndMirroring_whenRemoteIsDeleted_thenLocalIsDeleted() async throws {
|
||||
let profile = newProfile()
|
||||
let localProfiles = [profile]
|
||||
let repository = InMemoryProfileRepository(profiles: localProfiles)
|
||||
let remoteRepository = InMemoryProfileRepository()
|
||||
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
|
||||
remoteRepository
|
||||
}, mirrorsRemoteRepository: true)
|
||||
|
||||
var didImport = false
|
||||
observeRemoteImport(sut) {
|
||||
didImport = true
|
||||
}
|
||||
try await wait(sut) {
|
||||
try await $0.observeLocal()
|
||||
try await $0.observeRemote(true)
|
||||
} until: {
|
||||
$0.headers.count == 1
|
||||
}
|
||||
try await wait(sut) { _ in
|
||||
remoteRepository.profiles = []
|
||||
} until: { _ in
|
||||
didImport
|
||||
}
|
||||
XCTAssertFalse(sut.hasProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileManagerTests {
|
||||
func newProfile(_ name: String = "", id: UUID? = nil, fingerprint: UUID? = nil) -> Profile {
|
||||
do {
|
||||
var builder = Profile.Builder(id: id ?? UUID())
|
||||
builder.name = name
|
||||
if let fingerprint {
|
||||
builder.attributes.fingerprint = fingerprint
|
||||
}
|
||||
return try builder.tryBuild()
|
||||
} catch {
|
||||
fatalError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForReady(_ sut: ProfileManager, importingRemote: Bool = true) async throws {
|
||||
try await wait(sut) {
|
||||
try await $0.observeLocal()
|
||||
try await $0.observeRemote(importingRemote)
|
||||
} until: {
|
||||
$0.isReady
|
||||
}
|
||||
}
|
||||
|
||||
func observeRemoteImport(_ sut: ProfileManager, block: @escaping () -> Void) {
|
||||
sut
|
||||
.didChange
|
||||
.sink {
|
||||
if case .stopRemoteImport = $0 {
|
||||
block()
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func wait(
|
||||
_ sut: ProfileManager,
|
||||
after action: (ProfileManager) async throws -> Void,
|
||||
until condition: @escaping (ProfileManager) -> Bool
|
||||
) async throws {
|
||||
let exp = expectation(description: "Wait")
|
||||
var wasMet = false
|
||||
sut.objectWillChange
|
||||
.sink {
|
||||
guard !wasMet else {
|
||||
return
|
||||
}
|
||||
if condition(sut) {
|
||||
wasMet = true
|
||||
exp.fulfill()
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
try await action(sut)
|
||||
await fulfillment(of: [exp], timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ extension IAPManager {
|
|||
productsAtBuild: Configuration.IAPManager.productsAtBuild
|
||||
)
|
||||
|
||||
static let sharedProcessor = ProfileProcessor(
|
||||
static let sharedProcessor = InAppProcessor(
|
||||
iapManager: sharedForApp,
|
||||
title: {
|
||||
Configuration.ProfileManager.sharedTitle($0)
|
||||
|
@ -44,7 +44,7 @@ extension IAPManager {
|
|||
isIncluded: {
|
||||
Configuration.ProfileManager.isIncluded($0, $1)
|
||||
},
|
||||
willSave: { _, builder in
|
||||
willRebuild: { _, builder in
|
||||
builder
|
||||
},
|
||||
willConnect: { iap, profile in
|
||||
|
|
Loading…
Reference in New Issue