passepartout-apple/Library/Tests/CommonLibraryTests/ProfileManagerTests.swift
Davide e761979134
Extend ProfileManager events (#964)
Especially for flaky tests:

- Do objectWillChange.send() _before_ performing the change
- Send more events to .didChange to be more deterministic about test
expectations
2024-11-28 22:56:12 +01:00

680 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 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", until: .filteredProfiles) {
$0.search(byName: "ar")
}
XCTAssertTrue(sut.isSearching)
XCTAssertEqual(sut.previews.count, 1)
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.requiredFeaturesCount, 1)
XCTAssertEqual(processor.willRebuildCount, 0)
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", until: .save(profile)) {
try await $0.save(profile)
}
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", until: .save(renamedProfile)) {
try await $0.save(renamedProfile)
}
XCTAssertEqual(sut.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: CommonLibraryTests.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", until: .remove([profile.id])) {
await $0.remove(withId: profile.id)
}
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: CommonLibraryTests.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: CommonLibraryTests.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 newName = sut.firstUniqueName(from: profile.name)
XCTAssertEqual(newName, "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", until: .localProfiles) {
try await $0.duplicate(profileWithId: profile.id)
}
XCTAssertEqual(sut.previews.count, 2)
try await wait(sut, "Duplicate 2", until: .localProfiles) {
try await $0.duplicate(profileWithId: profile.id)
}
XCTAssertEqual(sut.previews.count, 3)
try await wait(sut, "Duplicate 3", until: .localProfiles) {
try await $0.duplicate(profileWithId: profile.id)
}
XCTAssertEqual(sut.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", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
XCTAssertEqual(sut.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", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
XCTAssertEqual(sut.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)
try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
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)
try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
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", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
XCTAssertEqual(sut.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", until: .stopRemoteImport) {
$0.previews.count == 5
} after: { _ 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)
]
}
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
})
try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
XCTAssertEqual(sut.previews.count, 1)
try await wait(sut, "Remote reset", until: .stopRemoteImport) { _ in
remoteRepository.profiles = []
}
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(profiles: localProfiles)
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in
remoteRepository
}, mirrorsRemoteRepository: true)
try await wait(sut, "Remote import", until: .stopRemoteImport) {
try await $0.observeLocal()
try await $0.observeRemote(true)
}
XCTAssertEqual(sut.previews.count, 1)
try await wait(sut, "Remote reset", until: .stopRemoteImport) { _ in
remoteRepository.profiles = []
}
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", until: .ready) {
try await $0.observeLocal()
try await $0.observeRemote(importingRemote)
}
}
func wait(
_ sut: ProfileManager,
_ description: String,
until event: ProfileManager.Event,
condition: @escaping (ProfileManager) -> Bool = { _ in true },
after action: (ProfileManager) async throws -> Void
) async throws {
let exp = expectation(description: description)
var wasMet = false
sut.didChange
.sink {
guard !wasMet else {
return
}
if $0 == event, condition(sut) {
wasMet = true
exp.fulfill()
}
}
.store(in: &subscriptions)
try await action(sut)
await fulfillment(of: [exp], timeout: CommonLibraryTests.timeout)
}
}