Files
member-console/internal/server/operator_subscriptions_bundle_db_test.go
cgalo5758 dec031e6dc Name every ladder a grant leaves empty when it supersedes a bundle
#271. A grant on one ladder of a product that is a tier of several
ladders keeps ending the subscription's tier on all of them, and the
ladders the grant is not on stay empty until it ends; what changes is
that every place an operator or a member looks now says so. A new test
pins the console's behaviour the strings rest on: the subscription
holds nothing anywhere, the other ladder holds no position, the grant
delivers, and a reconcile of the subscription is queued, which pauses
its billing when it pays for nothing else.

The Issue grant form: an option now carries its own hint
(forms.Option.Hint, rendered as data-hint, and hint-on-choice.js shows
the chosen option's hint or the declared one). A product that would
supersede a tier of several ladders names them: "This grant would
supersede a tier a subscription pays for on Duo and Wiki; its billing
pauses until the grant ends." A product superseding a tier of one
ladder keeps the existing warning. An option's hint is not held to the
registry's 100-character cap, because the ladder names are the fact.

The Not delivering badge on the organization page's Billing card and
in the subscriptions list: the ladder's name rides the conferral shapes
(ListConferralShapes selects it, Rung.LadderName), and the title adds
"; nothing is delivered on {ladders}" for the ladders of the tier on
which no live position exists when the page renders: "Billing paused
while a grant holds Wiki; nothing is delivered on Duo." A ladder
something else holds is named in neither clause. The organization page
reads the default pool's positions it already holds; the list reads
them with one statement per page (ListLiveLadderIDsBySubscriptionIDs),
and a failed read shows "Failed to load deliveries." as the other reads
do. Whether a sentence says paused or continues is decided from the
deciding displacements only, as the reconcile decides the pause, so a
ladder a later subscription holds cannot turn a recorded pause into
"Billing continues"; when a named holder is not a grant the sentence
reads "Billing paused while other plans hold Duo and Wiki."

The member's Products page: on each ladder the grant does not hold, the
paid tier's card reads "Paused while Pro is granted on Wiki", or
"Billing continues while Pro is granted on Wiki" until Stripe confirms
the pause, and draws no control; the controls stay on the card of the
ladder the grant holds.

docs/models/plan-ladders-transitions.md, docs/plan-management.md and
docs/stripe.md say the same.

Tests: TestReconcile_AGrantOnOneLadderOfABundlePausesIt,
TestSupersededLadders, TestLadderNamesNeedsEveryName,
TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint,
TestSubscriptionsListNamesEveryLadderOfABundle,
TestOrgEnrollmentTwoLadderGolden (two states on their own World),
TestGrantHeldLadder, TestPaidPlanCardOnAVacatedLadder, and new rows in
TestDisplacedOnPage, TestDisplacementTitle,
TestOrgEnrollmentIssueGrantSupersedeHint, TestOrgEnrollmentReadFailures
and TestSubscriptionsListDisplacementReadFailures. TestMemberPlansGolden:
the seven paid-plus-under-granted-pro states draw Duo's Plus card with
the ladder line; the member and organization .json goldens gain the new
fields. The grant-hint walkthrough's own-hint step is a subtest that
skips, naming its precondition, because no demo state holds a
subscription to a product of two ladders.

Full suite on reset databases: 5143 passed, 15 skipped, none failed.
make screens: no page changed; no demo state holds such a subscription.
Hand captures cover the grant form, the badge and the member's cards.

Unseen by any test: hint-on-choice.js not setting the line's text; only
the browser walkthrough reads it, and its step skips. The hand captures
show it.

Breaks, all CAUGHT (44):
- DisplacedProducts passes over a product of several rungs
- displacedOnPage adds no emptied ladder (page rows)
- displacedOnPage adds no emptied ladder (golden)
- displacedOnPage names only the deciding ladder as held
- groupDisplaced keeps an emptied ladder the group names as held
- continuingSentence one subscription without the clause
- displacedOnPage ignores live (page rows)
- displacedOnPage ignores live (list)
- pausedSentence uses pausedHolder whatever the holders
- displacedOnPage takes grantsOnly over every holder the walk adds
- organization page drops the positions read error from the badge
- subscriptionDisplacementTitles ignores the live ladder read error
- ListLiveLadderIDsBySubscriptionIDs answers no row
- ListConferralShapes selects no name (list)
- ListConferralShapes selects no name (golden)
- RungsByProduct leaves LadderName empty
- supersededLadders answers for a product whose superseded provisions each hold one ladder
- supersededLadders keeps only the ladders the product is a tier of
- ladderNames answers the names it read when one is missing
- issuanceProductOptions leaves Option.Hint empty (page)
- issuanceProductOptions leaves Option.Hint empty (golden)
- issueGrantSupersedeHintOn answers for no ladders
- selectOptions does not copy Hint
- selectOptions copies Hint without ShowsHint
- choiceHint answers the declared hint for a chosen option with its own
- HintDefault left empty
- HintDefault set when no option carries its own hint
- ui_form_field.html without data-hint on the option
- ui_form_field.html without data-hint-default on the line
- grantHolds without its DefaultSourced test
- grantHeldLadder walks rows in place of ladders
- grantHeldLadder without its subscription test
- setPaidUnderGrant draws the controls on a card with grantLadder
- template plain paused branch without the ladder
- template payment-due paused branch without the ladder
- template moves-to paused branch without the ladder
- template without the Billing continues branch
- buildLadder without the empty-ladder case
- buildLadder takes the heading's cancellation date from a card with grantLadder
- pausedSentence one subscription without the clause
- pausedSentence several subscriptions without the clause
- continuingSentence a count of the account without the clause
- continuingSentence several subscriptions without the clause
- groupDisplaced leaves the emptied ladders unsorted
2026-10-29 22:34:15 -05:00

64 lines
2.4 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"net/http"
"net/http/httptest"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// TestSubscriptionsListNamesEveryLadderOfABundle drives a subscription to a
// product of two ladders under a grant on one of them through the real
// statements (grant-names-every-emptied-ladder D2): the list's row title, which
// reads the live ladder statement and the conferral shapes' ladder names, names
// the ladder the grant holds and the one it emptied, and the organization
// page's card reads the same words. A grant on the emptied ladder removes it
// from the title.
func TestSubscriptionsListNamesEveryLadderOfABundle(t *testing.T) {
w := world.New(t, dbtest.Open(t))
org := w.Org("Bundled", w.OrgType("bundled"), w.Person("Owner"))
both := w.Product("Both")
w.Price(both, 1000)
higher := w.Product("Higher")
w.Price(higher, 2000)
farTier := w.Product("Far tier")
w.Price(farTier, 3000)
near := w.Ladder("Near", both, higher)
far := w.Ladder("Far", both, farTier)
w.BillingAccount(org, "Account")
sub := w.Subscribe(org, world.SubOpts{}, both)
w.MapSubscription(sub)
w.Issue(org, higher, world.IssueOpts{})
w.PauseCollection(sub)
f := displacedListFixture{w: w, orgA: org, subA: sub}
title := func(t *testing.T) (listed, card string) {
t.Helper()
data, _ := f.list(t, catalogReadsConfig(w))
listed, _ = titleOf(data, sub.ID)
request := httptest.NewRequest(http.MethodGet, "/operator/organizations/"+org.ID, nil)
card = catalogReadsHandler(t, catalogReadsConfig(w)).loadOrgEnrollmentData(request, org.ID, "").BillingSummary.DisplacementTitle
return listed, card
}
want := "Billing paused while a grant holds " + near.Name + "; nothing is delivered on " + far.Name + "."
listed, card := title(t)
if listed != want {
t.Errorf("the bundle's row title = %q, want %q", listed, want)
}
if listed != card {
t.Errorf("the bundle's list title %q differs from the organization page's %q", listed, card)
}
w.Issue(org, farTier, world.IssueOpts{})
want = "Billing paused while a grant holds " + near.Name + "."
if after, _ := title(t); after != want {
t.Errorf("the title after Far is granted = %q, want %q", after, want)
}
}