Problem/Motivation

A review mapped every behaviour in src/, the module file and the install file to the test that asserts it. Most of the money paths are well covered; these are not, ranked by what a regression would cost:

  1. The expiry sweep for self-scheduling subscriptions (the non-charge-based branch of the cron hook, and loadExpiredSubscriptions()). A regression leaves a lapsed subscriber with paid roles forever, or deactivates active rows. Only the charge-based branch is tested.
  2. Webhook replay: resolveCharge() on a charge already succeeded or failed. Replays are routine; a regression returns 500 to the provider forever or advances the anchor twice.
  3. Registration while a billing API is down: the user-insert hook's fail-open is only exercised with the failure flag set after the user exists.
  4. upgrade() with nothing to prorate (cheaper plan, or anchor in the past): untested whether the plan still swaps and no charge is made.
  5. The entitlement-role backfill's healing path never executes in any test; the update-path test only asserts that nobody gained a role.
  6. The engine's data guards: missing currency or interval, and a plan whose connector is not charge-based.
  7. syncPlans() per-connector exception isolation and the unknown-connector error.

Seven assertions are weaker than the behaviour they guard: four anchor checks that pass even when the anchor does not move (CycleEngineTest twice, DunningTest twice), a tax test that accepts a pending result as success, a reversal test that counts events where the names matter, a usage-rating test that checks for any charge rather than the amount, a retry-refusal test that checks only the call count, and a maintenance test that checks status alone where roles and dunning fields matter.

Two further gaps — the first period charge when the connector throws, and the duplicates resolver on a charge-based loser — are closed by the charge-outcomes issue's tests and are not repeated here.

Proposed resolution

Kernel tests for the seven gaps:

  • Expiry sweep: a self-scheduling plan with a role; one subscription expired yesterday, one expiring tomorrow; run cron; the first is off with the role gone, the second untouched.
  • Replay: resolve a pending charge as succeeded twice, then as failed; one anchor advance, one recorded charge, still succeeded, not past due.
  • Registration: set the solo connector's throw flag before creating the user; the user saves and an error is logged.
  • Upgrade without proration: cheaper plan with a future anchor, and pricier plan with a past anchor; NULL returned, plan swapped, no connector call.
  • Backfill: an active subscriber whose role was removed by hand is healed; a blocked user is not; the summary counts match.
  • Guards: no currency, and a charge-based plan on a self-scheduling connector; no charge record, no connector call.
  • syncPlans(): one connector throwing does not stop the others; an unknown connector ID throws.

And the six assertions tightened to the exact value, state or event list.

Remaining tasks

  • Write the tests; no code change expected unless a test finds one.

User interface changes

None.

API changes

None.

Data model changes

None.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

colan created an issue. See original summary.

colan’s picture

Design pass, after re-reading each listed path against 1.0.x as it stands following #3629968: Treat a charge exception as an unknown outcome, reap stale submitted charges, never bill or revive a subscription that is no longer live, and stop back-billing suspended time and #3629966: Return JSON as JSON, replace the raw prices map in api/my-subscription with a PlanPrice, and burn post-purchase tokens atomically. The seven gaps are still gaps; the code for each behaves as the summary expects, so no production change is foreseen. Notes on placement and on a few details the summary did not settle, then the decisions.

The seven gaps

  • Expiry sweep. The cron hook's non-charge-based branch flips status off and saves; role revocation follows from the granted_roles snapshot on save. A new ExpirySweepTest seeds a self-scheduling plan with a role and three subscriptions: expired yesterday, expiring tomorrow, and already off with a past expiry (the loader filters on status, so it must stay untouched and unlogged). It runs the real cron service and asserts status, role and the notice log line.
  • Webhook replay. resolveCharge() ignores a charge that is not submitted with a warning. In CycleEngineTest: a pending period charge resolved as succeeded twice, then as failed; one anchor advance, the record still succeeded, not past due, one settled-charge registration with the tax service, and the warning logged twice.
  • Registration during an outage. The solo connector's existing throw flag set before createUser(); the user exists afterwards, no subscription was created, and the hook's error is logged. In MultiConnectorSyncTest, next to the existing flag tests.
  • Upgrade without proration. upgrade() returns NULL when the prorated amount is zero or negative but still swaps plan, basis and roles and clears a pending downgrade. In CycleEngineTest: a cheaper plan with a future anchor and a pricier plan with a past anchor; NULL returned, plan and roles swapped, no charge record, no connector call, anchor untouched.
  • Backfill healing. subscription_manager_backfill_missing_entitlement_roles() is procedural but takes no request state, so a Kernel test calls it directly. A new EntitlementBackfillTest: an active subscriber stripped of the plan's role by hand is healed; one who already has it is scanned but not healed; a blocked subscriber and an inactive subscription are skipped; the summary is exactly ['scanned' => 2, 'healed' => 1, 'roles_added' => 1]. The $uid argument narrows to one user. The update-path test keeps its "nobody gained a role" assertion for the fixture.
  • Engine guards. Both guards sit after the zero-amount shortcut, so the subscriptions carry a non-zero amount. Missing currency: error logged, no charge record, no connector call, anchor untouched. A charge-based subscription whose connector is the self-scheduling test_connector: the same, with the "not charge-based" error. In CycleEngineTest.
  • syncPlans(). The test connectors' syncPlans() are empty, so two small additions to the test modules: TestConnectorBase::syncPlans() records its plugin ID in state, and SoloConnector::syncPlans() throws under the existing remote-throw flag. The test asserts every other connector synced when the solo one threw, the error names the connector, and an unknown ID throws InvalidArgumentException before any connector runs.

The weak assertions

Re-reading finds four loose anchor checks, not three: two in CycleEngineTest (zero-amount with recording off; cron end to end) and two in DunningTest (retry success; proration retry then period). All four become the exact next anchor from computeNextAnchor(). The others, as the summary has them: the tax proration test asserts the charge succeeded and its taxed amount rather than that a charge exists; the reversal idempotency test asserts the event name list; the usage-rating zero-base test asserts the connector received one charge of exactly 2.00; the retry-refusal test also asserts the record is still succeeded, the subscription not past due, the attempt and anchor unchanged; the duplicates resolution test also asserts the loser's role is gone and the winner's intact.

Decisions to ratify

  1. Two new Kernel classes, ExpirySweepTest and EntitlementBackfillTest; the other gaps join the existing class for their subject.
  2. The two test-module additions for syncPlans(): a sync recorder in the shared test base and the solo connector throwing under its existing flag.
  3. All four loose anchor assertions are tightened, one more than the summary counted.
  4. No production code change is expected. If a test finds a defect, it is fixed in this MR in its own commit and called out in the implementation comment, rather than filed separately: the point of the issue is that these paths are right before beta7.
colan’s picture

Issue summary: View changes
Status: Active » Needs review

Implemented in the MR, in three commits, following the four decisions ratified in #2. No production code changed: every path behaved as the summary expected, so decision 4's fallback was not needed.

The seven gaps

  • Expiry sweep — ExpirySweepTest runs the real cron service over three self-scheduling subscriptions: expired yesterday (ends up off, the owner's role gone), expiring tomorrow (untouched, role kept), already off with a past expiry (untouched). Exactly one "Expired subscription" notice is logged, naming the lapsed one.
  • Webhook replay — CycleEngineTest::testResolutionReplayIsIgnored(): a pending period charge resolved as succeeded twice and then as failed. One anchor advance to the exact next anchor, the record still succeeded with no failure message, not past due, one charge record, one connector call, and the "Ignoring resolution" warning logged twice.
  • Registration during an outage — MultiConnectorSyncTest::testRegistrationSurvivesConnectorOutage(): the solo connector's throw flag set before createUser(). The account exists, no subscription was created, and the hook's error is logged once at error level with the user ID and the connector's message.
  • Upgrade without proration — testUpgradeWithoutProrationSwapsPlanOnly(): a cheaper plan mid-cycle and a pricier plan with the anchor passed. Both return NULL, swap plan, basis and roles, leave the anchor, and open no charge record and no connector call.
  • Backfill healing — EntitlementBackfillTest calls subscription_manager_backfill_missing_entitlement_roles() directly. The role is stripped at the database level, since a user save re-unions the roles of active subscriptions (which is how the loss happened in the wild). A stripped active subscriber is healed, an intact one is scanned but not healed, a blocked account and an inactive subscription are skipped, and the summary is exactly ['scanned' => 2, 'healed' => 1, 'roles_added' => 1]. A second test shows the $uid argument narrowing the scan.
  • Engine guards — testMissingCurrencyIsSkipped() and testNonChargeBasedConnectorIsSkipped(): error logged with the exact message, no charge record, no connector call, anchor untouched.
  • syncPlans() — testSyncPlansIsolatesConnectorFailures() asserts every connector extending the recording test base synced while the solo connector threw, and the error is logged against solo_connector; testSyncPlansUnknownConnectorThrows() asserts the InvalidArgumentException and that no connector ran.

The weak assertions

The four loose anchor checks (two in CycleEngineTest, two in DunningTest) expect the exact next anchor from computeNextAnchor(). The retry-refusal test also asserts the record is still succeeded with attempt 1, the subscription not past due with zero retry attempts, the anchor unchanged, and no event fired. The tax proration test asserts the charge succeeded and the connector was asked for the taxed amount. The reversal replay test asserts the event names. The usage-rating zero-base test asserts the connector received one charge of exactly 2.00 CAD. The duplicates resolution test gives both plans a role and asserts the shared owner ends with exactly the winner's role, and the winner keeps its snapshot.

One thing learned on the way, for the record: a deactivated subscription keeps its granted_roles snapshot (only a plan change clears it). That is by design, so the resolution test asserts the owner's roles rather than the loser's snapshot.

Test modules

TestConnectorBase::syncPlans() records its plugin ID in state; SoloConnector::syncPlans() throws under its existing remote-throw flag.

Suite

222 Kernel tests (ten added), 6 Functional tests untouched; phpcs clean. No change record: nothing outside tests/ changed.

  • colan committed 2e2e7779 on 1.0.x
    Merge branch '3629969-coverage-hardening' into '1.0.x'
    
    Resolve #3629969...

  • colan committed 794231b5 on 1.0.x
    Issue #3629969: Tighten the remaining weak assertions
    
    The two loose...

  • colan committed 9a7ce56d on 1.0.x
    Issue #3629969: Tests for the expiry sweep, webhook replay, registration...

  • colan committed 40e56b86 on 1.0.x
    Issue #3629969: Let the test connectors record and fail plan syncs...
colan’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.