Skip to content

fix(test): make the subscription-lapse test deterministic - #14

Merged
weskcode merged 2 commits into
mainfrom
fix/storekit-expiration-flake
Sep 8, 2026
Merged

weskcode merged 2 commits into
mainfrom
fix/storekit-expiration-flake

Conversation

@weskcode

@weskcode weskcode commented Sep 8, 2026

Copy link
Copy Markdown
Owner

StoreManagerStoreKitTests.expirationDropsEntitlement failed intermittently on the SunHatUnitTests scheme while passing on SunHat — not a scheme difference, and not machine load.

Root cause. SunHat.storekit sets _billingGracePeriodEnabled: true, and makeSession() calls resetToDefaultState(), which restores it for every test. With grace period on, session.expireSubscription(...) is ambiguous: the subscription can enter a grace period instead of lapsing. Grace keeps the transaction in Transaction.currentEntitlements with its original expiration date — which is exactly the failure signature we saw: state .active, activeProductID still monthly, expirationDate = 2026-10-06, the untouched original expiry one month out from the purchase.

The manager was behaving correctly. The test was asserting a lapse against a session that had entered grace.

What ruled out a timeout. The failure was bimodal:

expiration test other 5 StoreKit tests, same run
failing run 6.47 s, never converged 0.02–0.15 s each
isolated runs (3/3 pass) 0.046 s / 0.268 s fast

Sub-second or never. A starved process would have slowed the whole suite, and it didn't.

Fix.

  • makeSession() clears billingGracePeriodIsEnabled and shouldEnterBillingRetryOnRenewal, so "expire" means "lapse" in this suite. Grace-period and billing-retry mapping stay covered by three tests in AdFreeEntitlementResolverTests that drive the resolver directly.
  • refreshUntil() polls against a 20 s wall-clock deadline instead of 50 fixed attempts, so load can't clip the budget. The fast path still returns as soon as state converges.

Test-only change; no production code touched.

SunHat.storekit enables _billingGracePeriodEnabled, and makeSession()
calls resetToDefaultState(), which restores it for every test. With grace
period on, expireSubscription() is ambiguous: the subscription can enter
a grace period instead of lapsing, and grace keeps the transaction in
Transaction.currentEntitlements with its ORIGINAL expiration date. The
manager then correctly reports .active and the lapse assertions fail.

The failure was bimodal, which is what gave it away — the test either
converged in under 0.3s or never converged at all, while the other five
StoreKit tests in the same run finished in 0.02-0.15s each. A starved
process would have slowed all of them.

- makeSession() now clears billingGracePeriodIsEnabled and
  shouldEnterBillingRetryOnRenewal, so "expire" means "lapse" here.
  Grace-period and billing-retry mapping stay covered by three tests in
  AdFreeEntitlementResolverTests, which drive the resolver directly.
- refreshUntil() polls against a 20s wall-clock deadline instead of 50
  fixed attempts, so machine load can't clip the budget. The fast path
  still returns as soon as the state converges.
StoreManagerStoreKitTests hung for a full 40 minute run budget on this
machine while its .serialized siblings sat waiting behind it, so the
run ended with no results at all instead of one red suite. Added
.timeLimit(.minutes(2)) so a hang now costs two minutes instead of the
whole run.
@weskcode
weskcode merged commit 121bd7b into main Sep 8, 2026
1 check passed
@weskcode
weskcode deleted the fix/storekit-expiration-flake branch September 8, 2026 21:52
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