From b782cff26d1966a4b35114defc549b5e8619b639 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:52:37 +0200 Subject: [PATCH] Implement MyContactFeature --- Example/example-app/Package.swift | 9 + .../example-app/Sources/AppFeature/App.swift | 11 +- .../MyContactFeature/MyContactFeature.swift | 111 +++++++++++- .../MyContactFeature/MyContactView.swift | 61 ++++++- .../MyContactFeatureTests.swift | 170 +++++++++++++++++- 5 files changed, 351 insertions(+), 11 deletions(-) diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index faa8a155..6aa45316 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -145,10 +145,19 @@ let package = Package( .target( name: "MyContactFeature", dependencies: [ + .target(name: "ErrorFeature"), .product( name: "ComposableArchitecture", package: "swift-composable-architecture" ), + .product( + name: "ComposablePresentation", + package: "swift-composable-presentation" + ), + .product( + name: "ElixxirDAppsSDK", + package: "elixxir-dapps-sdk-swift" + ), ], swiftSettings: swiftSettings ), diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index b106e28a..65f4728e 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -25,6 +25,7 @@ extension AppEnvironment { static func live() -> AppEnvironment { let clientSubject = CurrentValueSubject<Client?, Never>(nil) let identitySubject = CurrentValueSubject<Identity?, Never>(nil) + let contactSubject = CurrentValueSubject<Data?, Never>(nil) let mainScheduler = DispatchQueue.main.eraseToAnyScheduler() let bgScheduler = DispatchQueue( label: "xx.network.dApps.ExampleApp.bg", @@ -58,7 +59,15 @@ extension AppEnvironment { mainScheduler: mainScheduler, error: ErrorEnvironment() ), - myContact: MyContactEnvironment() + myContact: MyContactEnvironment( + getClient: { clientSubject.value }, + getIdentity: { identitySubject.value }, + observeContact: { contactSubject.eraseToAnyPublisher() }, + updateContact: { contactSubject.value = $0 }, + bgScheduler: bgScheduler, + mainScheduler: mainScheduler, + error: ErrorEnvironment() + ) ) ) } diff --git a/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift index bd520703..317b646f 100644 --- a/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift +++ b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift @@ -1,33 +1,138 @@ +import Combine import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature public struct MyContactState: Equatable { public init( - id: UUID + id: UUID, + contact: Data? = nil, + isMakingContact: Bool = false, + error: ErrorState? = nil ) { self.id = id + self.contact = contact + self.isMakingContact = isMakingContact + self.error = error } public var id: UUID + public var contact: Data? + public var isMakingContact: Bool + public var error: ErrorState? } public enum MyContactAction: Equatable { case viewDidLoad + case observeMyContact + case didUpdateMyContact(Data?) + case makeContact + case didFinishMakingContact(NSError?) + case didDismissError + case error(ErrorAction) } public struct MyContactEnvironment { - public init() {} + public init( + getClient: @escaping () -> Client?, + getIdentity: @escaping () -> Identity?, + observeContact: @escaping () -> AnyPublisher<Data?, Never>, + updateContact: @escaping (Data?) -> Void, + bgScheduler: AnySchedulerOf<DispatchQueue>, + mainScheduler: AnySchedulerOf<DispatchQueue>, + error: ErrorEnvironment + ) { + self.getClient = getClient + self.getIdentity = getIdentity + self.observeContact = observeContact + self.updateContact = updateContact + self.bgScheduler = bgScheduler + self.mainScheduler = mainScheduler + self.error = error + } + + public var getClient: () -> Client? + public var getIdentity: () -> Identity? + public var observeContact: () -> AnyPublisher<Data?, Never> + public var updateContact: (Data?) -> Void + public var bgScheduler: AnySchedulerOf<DispatchQueue> + public var mainScheduler: AnySchedulerOf<DispatchQueue> + public var error: ErrorEnvironment } public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContactEnvironment> { state, action, env in switch action { case .viewDidLoad: + return .merge([ + .init(value: .observeMyContact), + ]) + + case .observeMyContact: + struct EffectId: Hashable { + let id: UUID + } + return env.observeContact() + .removeDuplicates() + .map(MyContactAction.didUpdateMyContact) + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + .cancellable(id: EffectId(id: state.id), cancelInFlight: true) + + case .didUpdateMyContact(let contact): + state.contact = contact + return .none + + case .makeContact: + state.isMakingContact = true + return Effect.future { fulfill in + guard let identity = env.getIdentity() else { + fulfill(.success(.didFinishMakingContact(NoIdentityError() as NSError))) + return + } + do { + env.updateContact(try env.getClient()?.makeContactFromIdentity(identity: identity)) + fulfill(.success(.didFinishMakingContact(nil))) + } catch { + fulfill(.success(.didFinishMakingContact(error as NSError))) + } + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didFinishMakingContact(let error): + state.isMakingContact = false + if let error = error { + state.error = ErrorState(error: error) + } + return .none + + case .didDismissError: + state.error = nil + return .none + + case .error(_): return .none } } +public struct NoIdentityError: Error, LocalizedError { + public init() {} +} + #if DEBUG extension MyContactEnvironment { - public static let failing = MyContactEnvironment() + public static let failing = MyContactEnvironment( + getClient: { fatalError() }, + getIdentity: { fatalError() }, + observeContact: { fatalError() }, + updateContact: { _ in fatalError() }, + bgScheduler: .failing, + mainScheduler: .failing, + error: .failing + ) } #endif diff --git a/Example/example-app/Sources/MyContactFeature/MyContactView.swift b/Example/example-app/Sources/MyContactFeature/MyContactView.swift index 746a5317..88f9d4b8 100644 --- a/Example/example-app/Sources/MyContactFeature/MyContactView.swift +++ b/Example/example-app/Sources/MyContactFeature/MyContactView.swift @@ -1,4 +1,7 @@ import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature import SwiftUI public struct MyContactView: View { @@ -9,17 +12,65 @@ public struct MyContactView: View { let store: Store<MyContactState, MyContactAction> struct ViewState: Equatable { - init(state: MyContactState) {} + let contact: Data? + let isMakingContact: Bool + + init(state: MyContactState) { + contact = state.contact + isMakingContact = state.isMakingContact + } + + var isLoading: Bool { + isMakingContact + } } public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in - Text("MyContactView") - .navigationTitle("My contact") - .task { - viewStore.send(.viewDidLoad) + Form { + Section { + Text(string(for: viewStore.contact)) + .textSelection(.enabled) } + + Section { + Button { + viewStore.send(.makeContact) + } label: { + HStack { + Text("Make contact from identity") + Spacer() + if viewStore.isMakingContact { + ProgressView() + } + } + } + } + .disabled(viewStore.isLoading) + } + .navigationTitle("My contact") + .navigationBarBackButtonHidden(viewStore.isLoading) + .task { + viewStore.send(.viewDidLoad) + } + .sheet( + store.scope( + state: \.error, + action: MyContactAction.error + ), + onDismiss: { + viewStore.send(.didDismissError) + }, + content: ErrorView.init(store:) + ) + } + } + + func string(for contact: Data?) -> String { + guard let contact = contact else { + return "No contact" } + return String(data: contact, encoding: .utf8) ?? "Decoding error" } } diff --git a/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift index dd026572..862be08d 100644 --- a/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift +++ b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -1,8 +1,174 @@ +import Combine +import ComposableArchitecture +import CustomDump +import ElixxirDAppsSDK +import ErrorFeature import XCTest @testable import MyContactFeature final class MyContactFeatureTests: XCTestCase { - func testExample() { - XCTAssert(true) + func testViewDidLoad() { + let myContactSubject = PassthroughSubject<Data?, Never>() + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.observeContact = { myContactSubject.eraseToAnyPublisher() } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.viewDidLoad) + store.receive(.observeMyContact) + + bgScheduler.advance() + let contact = "\(Int.random(in: 100...999))".data(using: .utf8)! + myContactSubject.send(contact) + mainScheduler.advance() + + store.receive(.didUpdateMyContact(contact)) { + $0.contact = contact + } + + myContactSubject.send(nil) + mainScheduler.advance() + + store.receive(.didUpdateMyContact(nil)) { + $0.contact = nil + } + + myContactSubject.send(completion: .finished) + mainScheduler.advance() + } + + func testMakeContact() { + let identity = Identity.stub() + let newContact = "\(Int.random(in: 100...999))".data(using: .utf8)! + var didMakeContactFromIdentity = [Identity]() + var didUpdateContact = [Data?]() + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.getClient = { + var client = Client.failing + client.makeContactFromIdentity.get = { identity in + didMakeContactFromIdentity.append(identity) + return newContact + } + return client + } + env.updateContact = { didUpdateContact.append($0) } + env.getIdentity = { identity } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.makeContact) { + $0.isMakingContact = true + } + + bgScheduler.advance() + + XCTAssertNoDifference(didMakeContactFromIdentity, [identity]) + XCTAssertNoDifference(didUpdateContact, [newContact]) + + mainScheduler.advance() + + store.receive(.didFinishMakingContact(nil)) { + $0.isMakingContact = false + } + } + + func testMakeContactWithoutIdentity() { + let error = NoIdentityError() as NSError + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.getIdentity = { nil } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.makeContact) { + $0.isMakingContact = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFinishMakingContact(error)) { + $0.isMakingContact = false + $0.error = ErrorState(error: error) + } + + store.send(.didDismissError) { + $0.error = nil + } + } + + func testMakeContactFailure() { + let error = NSError(domain: "test", code: 1234) + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.getClient = { + var client = Client.failing + client.makeContactFromIdentity.get = { _ in throw error } + return client + } + env.getIdentity = { .stub() } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.makeContact) { + $0.isMakingContact = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFinishMakingContact(error)) { + $0.isMakingContact = false + $0.error = ErrorState(error: error) + } + + store.send(.didDismissError) { + $0.error = nil + } + } +} + +private extension Identity { + static func stub() -> Identity { + Identity( + id: "\(Int.random(in: 100...999))".data(using: .utf8)!, + rsaPrivatePem: "\(Int.random(in: 100...999))".data(using: .utf8)!, + salt: "\(Int.random(in: 100...999))".data(using: .utf8)!, + dhKeyPrivate: "\(Int.random(in: 100...999))".data(using: .utf8)! + ) } } -- GitLab