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:
Davide 2024-11-21 16:03:57 +01:00 committed by GitHub
parent c7cef70eed
commit a93fcd4c66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 917 additions and 96 deletions

View File

@ -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
}

View File

@ -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))

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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>?
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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