Skip to content

Wrap StoreKit.Transaction so a purchase can be mocked - #6

Merged
Ramiz69 merged 2 commits into
mainfrom
feat/wrap-transaction
Sep 5, 2026
Merged

Wrap StoreKit.Transaction so a purchase can be mocked#6
Ramiz69 merged 2 commits into
mainfrom
feat/wrap-transaction

Conversation

@Ramiz69

@Ramiz69 Ramiz69 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

The last thing keeping PurchasesProtocol from being mockable, which is the reason the protocol exists.

The problem

purchase(productID:) returned a StoreKit.Transaction. That type has no public initializer and cannot be constructed at all, so a stand-in implementation could only ever throw from purchase — the successful path was impossible to express, and therefore impossible to test against.

2.0 fixed the same problem for StoreProduct, but purchase still handed back a raw transaction, so the one method that matters most for a paywall stayed untestable.

The change

StoreTransaction follows the shape StoreProduct already uses: the kit builds it from the StoreKit value and keeps that value on .transaction, and a public initializer takes the fields directly, leaving .transaction nil.

It surfaces what entitlement code actually reads — both identifiers, both purchase dates, expirationDate, revocationDate, isUpgraded, purchasedQuantity, appAccountToken and subscriptionGroupID. Anything it does not carry is still reachable through the raw transaction.

The identity fields default to the transaction itself: a one-off purchase has no original distinct from itself, and repeating both identifiers at every call site would be noise.

Verified from outside the module

A consumer package building a mock against the public API only:

purchase() успешна из мока:     ✓ tx.id=1001 product=pro.monthly
  tx.transaction == nil:        true
  tx.originalID (дефолт = id):  1001
  неизвестный id бросает:       ✓ invalidProductID("нет-такого")

Tests go from 24 in 5 suites to 30 in 6, including the case that could not be written before: a stand-in returning a successful purchase.

Breaking

The return type of purchase(productID:) changes on both the protocol and the manager, so this is source-breaking for callers that name the type and for conformers. It lands as 3.0.0 rather than being folded into the already-published 2.0.0 tag. README asks for 3.0.0 and splits the migration notes so someone on 2.0 sees only the one entry that affects them.

purchase(productID:) returned a StoreKit.Transaction. That type has no public
initializer and cannot be constructed at all, so a stand-in implementation of
PurchasesProtocol could only ever throw from purchase — the successful path was
impossible to express and therefore impossible to test against. It was the last
thing keeping the protocol from being mockable, which is the reason the
protocol exists.

StoreTransaction follows StoreProduct: the kit builds it from the StoreKit
value and keeps that value on `transaction`, and a public initializer takes the
fields directly, leaving `transaction` nil. It surfaces what entitlement code
actually reads — the two identifiers and purchase dates, expiration,
revocation, upgrade state, quantity, account token and subscription group. The
raw transaction stays reachable for anything it does not carry.

The identity fields default to the transaction itself: a one-off purchase has
no original distinct from itself, and repeating both identifiers at every call
site would be noise.

This changes the return type of purchase(productID:) on both the protocol and
the manager, so it is source-breaking for callers that name the type and for
conformers. Verified from outside the module: a mock now returns a successful
purchase, reports nil for the backing transaction, and still throws for an
unknown identifier.
Wrapping StoreKit.Transaction changes the return type of purchase(productID:),
which is source-breaking, so it lands as 3.0.0 rather than on top of the 2.0.0
tag that is already published.

The migration section now separates the single change that affects someone on
2.0 from the full list that affects someone still on 1.x, so neither audience
has to work out which entries apply to them.
@Ramiz69
Ramiz69 merged commit 9187453 into main Sep 5, 2026
1 check passed
@Ramiz69
Ramiz69 deleted the feat/wrap-transaction branch September 5, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant