passepartout-apple/Passepartout/Library/Tests/CommonLibraryTests/ProfileManagerTests.swift

734 lines
25 KiB
Swift

//
// ProfileManagerTests.swift
// Passepartout
//
// Created by Davide De Rosa on 9/12/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 Combine
@testable import CommonLibrary
import Foundation
import PassepartoutKit
import XCTest
@MainActor
final class ProfileManagerTests: XCTestCase {
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.previews.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.previews.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.previews.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.previews.count, 2)
try await wait(sut, "Search") {
$0.search(byName: "ar")
} until: {
$0.previews.count == 1
}
XCTAssertTrue(sut.isSearching)
let found = try XCTUnwrap(sut.previews.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.previews.count, 1)
XCTAssertEqual(sut.previews.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, "Save") {
try await $0.save(profile)
} until: {
$0.hasProfiles
}
XCTAssertEqual(sut.previews.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.previews.first?.id, profile.id)
var builder = profile.builder()
builder.name = "newName"
let renamedProfile = try builder.tryBuild()
try await wait(sut, "Save") {
try await $0.save(renamedProfile)
} until: {
$0.previews.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, isLocal: false)
XCTAssertEqual(processor.willRebuildCount, 0)
}
func test_givenRepositoryAndProcessor_whenSaveLocal_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, isLocal: 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, "Remove") {
await $0.remove(withId: profile.id)
} until: {
!$0.hasProfiles
}
XCTAssertTrue(sut.previews.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.previews.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, "Duplicate 1") {
try await $0.duplicate(profileWithId: profile.id)
} until: {
$0.previews.count == 2
}
try await wait(sut, "Duplicate 2") {
try await $0.duplicate(profileWithId: profile.id)
} until: {
$0.previews.count == 3
}
try await wait(sut, "Duplicate 3") {
try await $0.duplicate(profileWithId: profile.id)
} until: {
$0.previews.count == 4
}
XCTAssertEqual(sut.previews.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, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: {
$0.previews.count == allProfiles.count
}
XCTAssertEqual(Set(sut.previews), Set(allProfiles.map { ProfilePreview($0) }))
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, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: {
$0.previews.count == 4 // unique IDs
}
sut.previews.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("Unknown profile: \($0.id)")
}
}
}
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 wait(sut, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: { _ in
didImport
}
XCTAssertEqual(processor.isIncludedCount, allProfiles.count)
XCTAssertEqual(Set(sut.previews), Set(localProfiles.map { ProfilePreview($0) }))
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 wait(sut, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: { _ in
didImport
}
try sut.previews.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("Unknown profile: \($0.id)")
}
}
}
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, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: {
$0.previews.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, "Multiple imports") { _ 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.previews.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("Unknown profile: \($0.id)")
}
}
}
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, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: {
$0.previews.count == 1
}
try await wait(sut, "Remote reset") { _ in
remoteRepository.profiles = []
} until: { _ in
didImport
}
XCTAssertEqual(sut.previews.count, 1)
XCTAssertEqual(sut.previews.first, ProfilePreview(profile))
}
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, "Remote import") {
try await $0.observeLocal()
try await $0.observeRemote(true)
} until: {
$0.previews.count == 1
}
try await wait(sut, "Remote reset") { _ 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, "Ready") {
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,
_ description: String,
after action: (ProfileManager) async throws -> Void,
until condition: @escaping (ProfileManager) -> Bool
) async throws {
let exp = expectation(description: description)
var wasMet = false
Publishers.Merge(
sut.objectWillChange,
sut.didChange.map { _ in }
)
.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)
}
}