woocommerce / woocommerce-ios

WooCommerce iOS app
https://www.woocommerce.com/mobile
GNU General Public License v2.0
314 stars 113 forks source link

[Woo POS] Extract payment code to aggregate model #14450

Closed joshheald closed 1 day ago

joshheald commented 3 days ago

Closes: #14407

Description

This PR moves payment related code (excluding onboarding) from the TotalsViewModel to the new aggregate model.

Again, apologies that this is so big... but any options to make it smaller appeared to make it less clear and testable as well.

For the most part, this was a lift-and-shift operation, but I made some changes. In particular, I :

Some things that were tested before, aren't any more. The most complex example of this is TotalsView.isShowingCardReaderStatus – however, it's not substantially different to the existing cardReaderViewLayout on the same View.

I have found that EnvironmentObject makes it impossible to unit test the views which rely on posModel. If we want to go down that path we can, but we'll lose the convenience of the system dependency injection. It's a shame there's no good test hook for this though.

We could potentially keep EnvironmentObject for use by deeply nested but simple views, and pass the posModel in to the top-level views... but it is likely to make it harder to break views in to smaller components, so I've not done it for now.

Testing information

Broadly, this requires testing of payment and navigation between item list, checkout, and payment states.

That should include errors, both in fetching the order and completing the payment, and checking that editing/starting a new order works as expected.

Screenshots

https://github.com/user-attachments/assets/2c0eb97b-eac0-4489-8adc-eb131158470d

https://github.com/user-attachments/assets/ff8ba731-f402-413c-97f2-49a8c3d54847


Reviewer (or Author, in the case of optional code reviews):

Please make sure these conditions are met before approving the PR, or request changes if the PR needs improvement:

dangermattic commented 3 days ago
2 Warnings
:warning: This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
:warning: This PR is assigned to the milestone 21.2. This milestone is due in less than 2 days.
Please make sure to get it merged by then or assign it to a milestone with a later deadline.

Generated by :no_entry_sign: Danger

wpmobilebot commented 3 days ago

WooCommerce iOS📲 You can test the changes from this Pull Request in WooCommerce iOS by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS WooCommerce iOS
Build Numberpr14450-6a545ec
Version21.1
Bundle IDcom.automattic.alpha.woocommerce
Commit6a545ec8b81ae1b885862a0609b2810adf2ab630
App Center BuildWooCommerce - Prototype Builds #11678

Automatticians: You can use our internal self-serve MC tool to give yourself access to App Center if needed.

joshheald commented 1 day ago

Payment works as expected! Tested payment failure state and success state. :shipit:

Thanks!

I have found that EnvironmentObject makes it impossible to unit test the views which rely on posModel. If we want to go down that path we can, but we'll lose the convenience of the system dependency injection. It's a shame there's no good test hook for this though.

Can you share a simple example just to understand more about this limitation?

Sure – see this branch for an example of testing something on CartView which relies on the posModel

In theory that would all work, but it doesn't because the first time we try to use the posModel in CartView, because our test calls shouldPreventCartEditing, we get this crash:

Fatal error: No ObservableObject of type PointOfSaleAggregateModel found. A View.environmentObject(_:) for PointOfSaleAggregateModel may be missing as an ancestor of this view.

You can see in CartTests:23 there's a commented out injection of the posModel via the environmentObject modifier, but we can't actually inject it that way – if we do, our sut stops being a CartView and becomes some View – no use to us for calling functions on it in the tests.

Attempting to cast it back to a CartView will also fail every time.

Additionally, we can't inject a mock here; EnvironmentObject matches based on concrete types, and protocols won't work. The same is true for @Environment when we get to using the Observation framework.

Unfortunately, SwiftUI's environment cannot be used to look up something which conforms to a protocol rather than a concrete type1. The new @Environment property wrapper introduced with the Observation framework requires an Observable-conforming type, and protocols cannot conform to other protocols1. – Perplexity

We could potentially keep EnvironmentObject for use by deeply nested but simple views, and pass the posModel in to the top-level views... but it is likely to make it harder to break views in to smaller components, so I've not done it for now.

I'm fine with going back to manual DI if the use of EnvironmentObject is making some logic not testable, even though it's quite manual with more code.

Potentially. However, I don't want to do that yet, because we haven't had the chance to really see the benefits of EnvironmentObject injection beyond the initial convenience.

For example, when I did my experiments for this project, one potential route is to have the itemlist show POSDisplayableItems: views which are provided via the Aggregate Model but built by underlying services. By making them views, they can be responsible for adding themselves to the cart or doing whatever else they need to (e.g. expand a variable product selection view)... but that's going to be a whole lot cleaner if they can get the aggregate model using automatic injection.

What do you think about the approach in 6a545ec – where we need tests for presentation logic, extract them to a view model without dependencies, and pass in any relevant states? We would want to avoid ever passing the whole posModel, to keep this clean.

joshheald commented 1 day ago

@jaclync ⬆️ sorry I forgot to tag you

jaclync commented 1 day ago

Thanks for sharing the example, it seems like the challenge is when we want to write unit tests on the SwiftUI view, which we don't do very often.

What do you think about the approach in https://github.com/woocommerce/woocommerce-ios/commit/6a545ec8b81ae1b885862a0609b2810adf2ab630 – where we need tests for presentation logic, extract them to a view model without dependencies, and pass in any relevant states? We would want to avoid ever passing the whole posModel, to keep this clean.

Yea I think keeping logic in a view model makes sense and the view stays lean this way. For cases where the logic depends on the aggregated model (which is pretty likely since the aggregated model has states for multiple views), it'd be great to be able to pass the individual states (hopefully observable, like binding?).