ZcashLightClientKit/Tests/TestUtils/TestCoordinator.swift

267 lines
9.0 KiB
Swift

//
// TestCoordinator.swift
// ZcashLightClientKit-Unit-Tests
//
// Created by Francisco Gindre on 4/29/20.
//
import Combine
import Foundation
import XCTest
@testable import ZcashLightClientKit
/// This is the TestCoordinator
/// What does it do? quite a lot.
/// Is it a nice "SOLID" "Clean Code" piece of source code?
/// Hell no. It's your testing overlord and you will be grateful it is.
class TestCoordinator {
enum CoordinatorError: Error {
case notDarksideWallet
case notificationFromUnknownSynchronizer
case notMockLightWalletService
case builderError
case seedRequiredForMigration
}
enum SyncThreshold {
case upTo(height: BlockHeight)
case latestHeight
}
enum DarksideData {
case `default`
case predefined(dataset: DarksideDataset)
case url(urlString: String, startHeigth: BlockHeight)
}
var cancellables: [AnyCancellable] = []
var completionHandler: ((SDKSynchronizer) async throws -> Void)?
var errorHandler: ((Error?) async -> Void)?
var spendingKey: UnifiedSpendingKey
let viewingKey: UnifiedFullViewingKey
var birthday: BlockHeight
var synchronizer: SDKSynchronizer
var service: DarksideWalletService
var databases: TemporaryTestDatabases
let network: ZcashNetwork
static let defaultEndpoint = LightWalletEndpoint(
address: Constants.address,
port: 9067,
secure: false,
singleCallTimeoutInMillis: 10000,
streamingCallTimeoutInMillis: 1000000
)
init(
alias: ZcashSynchronizerAlias = .default,
container: DIContainer,
walletBirthday: BlockHeight,
network: ZcashNetwork,
callPrepareInConstructor: Bool = true,
endpoint: LightWalletEndpoint = TestCoordinator.defaultEndpoint,
syncSessionIDGenerator: SyncSessionIDGenerator = UniqueSyncSessionIDGenerator(),
dbTracingClosure: ((String) -> Void)? = nil
) async throws {
await InternalSyncProgress(alias: alias, storage: UserDefaults.standard, logger: logger).rewind(to: 0)
let databases = TemporaryDbBuilder.build()
self.databases = databases
let initializer = Initializer(
container: container,
cacheDbURL: nil,
fsBlockDbRoot: databases.fsCacheDbRoot,
dataDbURL: databases.dataDB,
endpoint: endpoint,
network: network,
spendParamsURL: try __spendParamsURL(),
outputParamsURL: try __outputParamsURL(),
saplingParamsSourceURL: SaplingParamsSourceURL.tests,
alias: alias,
loggingPolicy: .default(.debug)
)
let derivationTool = DerivationTool(networkType: network.networkType)
self.spendingKey = try derivationTool.deriveUnifiedSpendingKey(
seed: Environment.seedBytes,
accountIndex: 0
)
self.viewingKey = try derivationTool.deriveUnifiedFullViewingKey(from: spendingKey)
self.birthday = walletBirthday
self.network = network
let liveService = LightWalletServiceFactory(endpoint: endpoint).make()
self.service = DarksideWalletService(endpoint: endpoint, service: liveService)
self.synchronizer = SDKSynchronizer(initializer: initializer)
subscribeToState(synchronizer: self.synchronizer)
if callPrepareInConstructor {
if case .seedRequired = try await prepare(seed: Environment.seedBytes) {
throw TestCoordinator.CoordinatorError.seedRequiredForMigration
}
}
}
deinit {
cancellables.forEach { $0.cancel() }
cancellables = []
}
func prepare(seed: [UInt8]) async throws -> Initializer.InitializationResult {
return try await synchronizer.prepare(with: seed, viewingKeys: [viewingKey], walletBirthday: self.birthday)
}
func stop() async throws {
await synchronizer.blockProcessor.stop()
completionHandler = nil
errorHandler = nil
}
func setDarksideWalletState(_ state: DarksideData) throws {
switch state {
case .default:
try service.useDataset(DarksideDataset.beforeReOrg.rawValue)
case .predefined(let dataset):
try service.useDataset(dataset.rawValue)
case .url(let urlString, _):
try service.useDataset(from: urlString)
}
}
func setLatestHeight(height: BlockHeight) throws {
try service.applyStaged(nextLatestHeight: height)
}
func sync(completion: @escaping (SDKSynchronizer) async throws -> Void, error: @escaping (Error?) async -> Void) async throws {
self.completionHandler = completion
self.errorHandler = error
try await synchronizer.start(retry: true)
}
// MARK: notifications
func subscribeToState(synchronizer: Synchronizer) {
synchronizer.stateStream
.sink(
receiveValue: { [weak self] state in
switch state.syncStatus {
case let .error(error):
self?.synchronizerFailed(error: error)
case .synced:
try! self?.synchronizerSynced()
default:
break
}
}
)
.store(in: &cancellables)
}
func synchronizerFailed(error: Error) {
Task(priority: .high) {
await self.errorHandler?(error)
}
}
func synchronizerSynced() throws {
if case .stopped = self.synchronizer.latestState.syncStatus {
LoggerProxy.debug("WARNING: notification received after synchronizer was stopped")
return
}
Task(priority: .high) {
try await self.completionHandler?(self.synchronizer)
}
}
}
extension TestCoordinator {
func resetBlocks(dataset: DarksideData) throws {
switch dataset {
case .default:
try service.useDataset(DarksideDataset.beforeReOrg.rawValue)
case .predefined(let blocks):
try service.useDataset(blocks.rawValue)
case .url(let urlString, _):
try service.useDataset(urlString)
}
}
func stageBlockCreate(height: BlockHeight, count: Int = 1, nonce: Int = 0) throws {
try service.stageBlocksCreate(from: height, count: count, nonce: nonce)
}
func applyStaged(blockheight: BlockHeight) throws {
try service.applyStaged(nextLatestHeight: blockheight)
}
func stageTransaction(_ transaction: RawTransaction, at height: BlockHeight) throws {
try service.stageTransaction(transaction, at: height)
}
func stageTransaction(url: String, at height: BlockHeight) throws {
try service.stageTransaction(from: url, at: height)
}
func latestHeight() async throws -> BlockHeight {
try await service.latestBlockHeight()
}
func reset(saplingActivation: BlockHeight, branchID: String, chainName: String) throws {
Task {
await self.synchronizer.blockProcessor.stop()
let config = await self.synchronizer.blockProcessor.config
let newConfig = CompactBlockProcessor.Configuration(
alias: config.alias,
fsBlockCacheRoot: config.fsBlockCacheRoot,
dataDb: config.dataDb,
spendParamsURL: config.spendParamsURL,
outputParamsURL: config.outputParamsURL,
saplingParamsSourceURL: config.saplingParamsSourceURL,
downloadBatchSize: config.downloadBatchSize,
retries: config.retries,
maxBackoffInterval: config.maxBackoffInterval,
rewindDistance: config.rewindDistance,
walletBirthdayProvider: config.walletBirthdayProvider,
saplingActivation: saplingActivation,
network: config.network
)
await self.synchronizer.blockProcessor.update(config: newConfig)
}
try service.reset(saplingActivation: saplingActivation, branchID: branchID, chainName: chainName)
}
func getIncomingTransactions() throws -> [RawTransaction]? {
return try service.getIncomingTransactions()
}
}
struct TemporaryTestDatabases {
var fsCacheDbRoot: URL
var dataDB: URL
}
enum TemporaryDbBuilder {
static func build() -> TemporaryTestDatabases {
let tempUrl = try! __documentsDirectory()
let timestamp = String(Int(Date().timeIntervalSince1970))
return TemporaryTestDatabases(
fsCacheDbRoot: tempUrl.appendingPathComponent("fs_cache_\(timestamp)"),
dataDB: tempUrl.appendingPathComponent("data_db_\(timestamp).db")
)
}
}
extension TestCoordinator {
static func loadResource(name: String, extension: String) -> Data {
let url = Bundle.module.url(forResource: name, withExtension: `extension`)!
return try! Data(contentsOf: url)
}
}