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