Add the offer decision and its table test
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
// Package plans decides which move a member is offered toward a plan: Checkout,
|
||||
// a switch, a downgrade at the period end, or none. It reads no database row
|
||||
// and calls no payment provider; the caller supplies the organization's
|
||||
// position on a ladder and the target's facts, and the Products page, Checkout,
|
||||
// the plan switch and its preview all ask the same function, so no surface
|
||||
// offers or accepts a move another refuses.
|
||||
package plans
|
||||
|
||||
// Tier is one rung of a ladder.
|
||||
type Tier struct {
|
||||
ProductID string
|
||||
Rank int32
|
||||
}
|
||||
|
||||
// Position is what an organization holds on one ladder.
|
||||
type Position struct {
|
||||
// Tiers are the ladder's rungs.
|
||||
Tiers []Tier
|
||||
// Current is the product of the rung the organization holds, "" when it
|
||||
// holds none.
|
||||
Current string
|
||||
// Subscribed is true when a subscription pays for the current rung, and
|
||||
// false when a grant, the organization type's default or a purchase holds
|
||||
// it.
|
||||
Subscribed bool
|
||||
// CancelScheduled is true when the subscription's cancellation is
|
||||
// scheduled.
|
||||
CancelScheduled bool
|
||||
// ScheduledTo is the product an active scheduled downgrade moves to.
|
||||
ScheduledTo string
|
||||
// Held is the held rung: the tier of a grant marked to resume, which the
|
||||
// subscription's end would put in place. Nil when there is none.
|
||||
Held *Tier
|
||||
// Predicted is true when a cancel prediction exists, which needs the
|
||||
// subscription's period end.
|
||||
Predicted bool
|
||||
// Follows is the product the prediction puts on this ladder, "" when it
|
||||
// puts nothing there.
|
||||
Follows string
|
||||
}
|
||||
|
||||
// Target is the product a move would land on and the price it would use.
|
||||
type Target struct {
|
||||
ProductID string
|
||||
// Listed is true when the product clears the member gate: published,
|
||||
// active and public.
|
||||
Listed bool
|
||||
Price Price
|
||||
}
|
||||
|
||||
// Price is the target price.
|
||||
type Price struct {
|
||||
// ID is "" when the product has no active price.
|
||||
ID string
|
||||
// Default is true when the price is the product's default active price.
|
||||
Default bool
|
||||
Recurring bool
|
||||
// Purchasable is true when the price's Stripe mapping carries an id the
|
||||
// key mode can reach.
|
||||
Purchasable bool
|
||||
}
|
||||
|
||||
// Kind is the mechanism a move uses.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// None is no move.
|
||||
None Kind = ""
|
||||
// Checkout creates a subscription.
|
||||
Checkout Kind = "checkout"
|
||||
// Switch moves a subscription to another paid rung.
|
||||
Switch Kind = "switch"
|
||||
// Cancel is the downgrade at the period end: it cancels the subscription,
|
||||
// after which the held rung or the free rung follows.
|
||||
Cancel Kind = "cancel"
|
||||
)
|
||||
|
||||
// Reason says why a move is not offered or is disabled.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
// NoReason accompanies an enabled move.
|
||||
NoReason Reason = ""
|
||||
|
||||
NotOnLadder Reason = "the target is not a tier of the ladder"
|
||||
Unplaced Reason = "the organization holds a product that is not a tier of the ladder"
|
||||
Current Reason = "the target is the current tier"
|
||||
Scheduled Reason = "a downgrade to the target is already scheduled"
|
||||
HeldMoveMade Reason = "the held rung's move is already made"
|
||||
HeldAbove Reason = "the held rung is above the current rung"
|
||||
BelowBoth Reason = "the target is below the held rung and the current rung"
|
||||
FreeRungNotFollowing Reason = "the free rung is not what follows a cancellation"
|
||||
Conferred Reason = "the free rung is conferred, never bought"
|
||||
BelowGrant Reason = "no subscription pays for the rung above the target"
|
||||
NotListed Reason = "the product is outside the member gate"
|
||||
NoPrice Reason = "the tier has no price"
|
||||
NotDefault Reason = "the price is not the product's default price"
|
||||
OneTime Reason = "the price is one-time"
|
||||
Unpurchasable Reason = "the price has no Stripe price the key can reach"
|
||||
OtherLadder Reason = "another ladder of the product forbids it"
|
||||
)
|
||||
|
||||
// Relation is the move relative to the current rung on the ladder. Offer sets
|
||||
// it on every answer from rule 4 on; it is empty on the answers of rules 1 to
|
||||
// 3, which name no move.
|
||||
type Relation string
|
||||
|
||||
const (
|
||||
// Available: the organization holds nothing on the ladder.
|
||||
Available Relation = "available"
|
||||
Upgrade Relation = "upgrade"
|
||||
Downgrade Relation = "downgrade"
|
||||
)
|
||||
|
||||
// Move is the answer of Offer.
|
||||
type Move struct {
|
||||
Kind Kind
|
||||
Enabled bool
|
||||
Reason Reason
|
||||
Relation Relation
|
||||
// Held marks the held rung.
|
||||
Held bool
|
||||
}
|
||||
|
||||
// Allows reports whether the move is an enabled move of kind k.
|
||||
func (m Move) Allows(k Kind) bool {
|
||||
return m.Kind == k && m.Enabled
|
||||
}
|
||||
|
||||
// ask is one question to Offer with the facts every rule reads.
|
||||
type ask struct {
|
||||
Position
|
||||
Target
|
||||
rank int32
|
||||
current int32
|
||||
rel Relation
|
||||
}
|
||||
|
||||
func (p Position) rankOf(productID string) (int32, bool) {
|
||||
for _, t := range p.Tiers {
|
||||
if t.ProductID == productID {
|
||||
return t.Rank, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Offer returns the move the position is offered toward the target, applying
|
||||
// these rules in order, the first match winning:
|
||||
//
|
||||
// 1. The target is not a tier of the ladder: none.
|
||||
// 2. The organization holds a product that is not a tier of the ladder: none.
|
||||
// 3. The target is the current product: none.
|
||||
// 4. A subscription pays, no cancellation is scheduled, and the target is the
|
||||
// scheduled downgrade's and not the held rung: none.
|
||||
// 5. The target is the held rung and the cancellation is scheduled: none.
|
||||
// 6. The target is the held rung above the current rung: none.
|
||||
// 7. The target is the held rung: Cancel. Cancelling buys nothing, so the
|
||||
// price rule does not apply.
|
||||
// 8. The target ranks below both the held rung and the current rung: none.
|
||||
// 9. A subscription pays, the target is the unpriced rank-0 tier below the
|
||||
// current rung, and a prediction that does not put the target on the ladder
|
||||
// exists or the cancellation is scheduled: none.
|
||||
// 10. The target is the unpriced rank-0 tier and the organization holds nothing
|
||||
// on the ladder: none, since that tier is conferred.
|
||||
// 11. The target ranks below the current rung and no subscription pays for it:
|
||||
// none, since a member cannot end a grant.
|
||||
// 12. The organization holds nothing on the ladder, or the target ranks above a
|
||||
// rung no subscription pays for: Checkout, through the price rule.
|
||||
// 13. The target is the unpriced rank-0 tier: Cancel.
|
||||
// 14. Otherwise: Switch, through the price rule.
|
||||
//
|
||||
// The price rule decides Checkout and Switch: a product outside the member
|
||||
// gate is offered none; a tier with no price is offered the move disabled; a
|
||||
// price that is not the default is offered none; a one-time price, or one with
|
||||
// no reachable Stripe price, is offered the move disabled.
|
||||
func Offer(pos Position, t Target) Move {
|
||||
rank, ok := pos.rankOf(t.ProductID)
|
||||
if !ok {
|
||||
return Move{Reason: NotOnLadder}
|
||||
}
|
||||
a := ask{Position: pos, Target: t, rank: rank, rel: Available}
|
||||
if pos.Current != "" {
|
||||
cur, ok := pos.rankOf(pos.Current)
|
||||
if !ok {
|
||||
return Move{Reason: Unplaced}
|
||||
}
|
||||
a.current = cur
|
||||
a.rel = Downgrade
|
||||
if rank > cur {
|
||||
a.rel = Upgrade
|
||||
}
|
||||
}
|
||||
if t.ProductID == pos.Current {
|
||||
return Move{Reason: Current}
|
||||
}
|
||||
if m, ok := a.heldRules(); ok {
|
||||
return m
|
||||
}
|
||||
if m, ok := a.offersNothing(); ok {
|
||||
return m
|
||||
}
|
||||
return a.route()
|
||||
}
|
||||
|
||||
func (a ask) isHeld() bool {
|
||||
return a.Held != nil && a.Held.ProductID == a.ProductID
|
||||
}
|
||||
|
||||
// freeRung reports whether the target is the unpriced rank-0 tier.
|
||||
func (a ask) freeRung() bool {
|
||||
return a.rank == 0 && a.Price.ID == ""
|
||||
}
|
||||
|
||||
func (a ask) answer(reason Reason) (Move, bool) {
|
||||
return Move{Reason: reason, Relation: a.rel, Held: a.isHeld()}, true
|
||||
}
|
||||
|
||||
// heldRules applies rules 4 to 8.
|
||||
func (a ask) heldRules() (Move, bool) {
|
||||
held := a.isHeld()
|
||||
switch {
|
||||
case a.Subscribed && !a.CancelScheduled && a.ProductID == a.ScheduledTo && !held:
|
||||
return a.answer(Scheduled)
|
||||
case held && a.CancelScheduled:
|
||||
return a.answer(HeldMoveMade)
|
||||
case held && a.rel == Upgrade:
|
||||
return a.answer(HeldAbove)
|
||||
case held:
|
||||
return Move{Kind: Cancel, Enabled: true, Relation: a.rel, Held: true}, true
|
||||
case a.Held != nil && a.rank < a.Held.Rank && a.rank < a.current:
|
||||
return a.answer(BelowBoth)
|
||||
}
|
||||
return Move{}, false
|
||||
}
|
||||
|
||||
// offersNothing applies rules 9 to 11.
|
||||
func (a ask) offersNothing() (Move, bool) {
|
||||
switch {
|
||||
case a.Subscribed && a.freeRung() && a.rel == Downgrade &&
|
||||
((a.Predicted && a.Follows != a.ProductID) || a.CancelScheduled):
|
||||
return a.answer(FreeRungNotFollowing)
|
||||
case a.freeRung() && a.rel == Available:
|
||||
return a.answer(Conferred)
|
||||
case a.rel == Downgrade && !a.Subscribed:
|
||||
return a.answer(BelowGrant)
|
||||
}
|
||||
return Move{}, false
|
||||
}
|
||||
|
||||
// route applies rules 12 to 14.
|
||||
func (a ask) route() Move {
|
||||
var m Move
|
||||
switch {
|
||||
case a.rel == Available || (a.rel == Upgrade && !a.Subscribed):
|
||||
m = priceRule(Checkout, a.Target)
|
||||
case a.freeRung():
|
||||
m = Move{Kind: Cancel, Enabled: true}
|
||||
default:
|
||||
m = priceRule(Switch, a.Target)
|
||||
}
|
||||
m.Relation = a.rel
|
||||
return m
|
||||
}
|
||||
|
||||
// priceRule decides a Checkout or a Switch toward the target.
|
||||
func priceRule(kind Kind, t Target) Move {
|
||||
p := t.Price
|
||||
switch {
|
||||
case !t.Listed:
|
||||
return Move{Reason: NotListed}
|
||||
case p.ID == "":
|
||||
return Move{Kind: kind, Reason: NoPrice}
|
||||
case !p.Default:
|
||||
return Move{Reason: NotDefault}
|
||||
case !p.Recurring:
|
||||
return Move{Kind: kind, Reason: OneTime}
|
||||
case !p.Purchasable:
|
||||
return Move{Kind: kind, Reason: Unpurchasable}
|
||||
}
|
||||
return Move{Kind: kind, Enabled: true}
|
||||
}
|
||||
|
||||
// Across combines one product's moves on the ladders it is a tier of. A
|
||||
// subscription to the product ends the incumbent position on every one of them
|
||||
// (whole-bundle supersession), so Checkout is offered only when every ladder
|
||||
// offers Checkout, and enabled only when every one is. When another ladder
|
||||
// offers anything else the answer is none, with OtherLadder. The answer carries
|
||||
// no relation: it differs per ladder. A single ladder's move is returned as it
|
||||
// is, since no other ladder is involved.
|
||||
func Across(moves []Move) Move {
|
||||
switch len(moves) {
|
||||
case 0:
|
||||
return Move{Reason: NotOnLadder}
|
||||
case 1:
|
||||
return moves[0]
|
||||
}
|
||||
out := Move{Kind: Checkout, Enabled: true}
|
||||
for _, m := range moves {
|
||||
if m.Kind != Checkout {
|
||||
return Move{Reason: OtherLadder}
|
||||
}
|
||||
if !m.Enabled && out.Enabled {
|
||||
out.Enabled = false
|
||||
out.Reason = m.Reason
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// OffLadder decides the offer for a product that is a tier of no ladder: the
|
||||
// price rule, with Checkout the only move.
|
||||
func OffLadder(t Target) Move {
|
||||
return priceRule(Checkout, t)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package plans
|
||||
|
||||
import "testing"
|
||||
|
||||
// The ladder under test: free (rank 0, unpriced), basic (1), standard (2),
|
||||
// plus (3), pro (4).
|
||||
var ladderTiers = []Tier{
|
||||
{ProductID: "free", Rank: 0},
|
||||
{ProductID: "basic", Rank: 1},
|
||||
{ProductID: "standard", Rank: 2},
|
||||
{ProductID: "plus", Rank: 3},
|
||||
{ProductID: "pro", Rank: 4},
|
||||
}
|
||||
|
||||
func tierOf(id string) *Tier {
|
||||
for _, t := range ladderTiers {
|
||||
if t.ProductID == id {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buyable is a default, recurring, reachable price.
|
||||
var buyable = Price{ID: "price", Default: true, Recurring: true, Purchasable: true}
|
||||
|
||||
// holds is a position on the ladder with the current rung held.
|
||||
func holds(current string, subscribed bool) Position {
|
||||
return Position{Tiers: ladderTiers, Current: current, Subscribed: subscribed}
|
||||
}
|
||||
|
||||
// target is a listed product with the buyable price; the free rung has none.
|
||||
func target(id string) Target {
|
||||
t := Target{ProductID: id, Listed: true, Price: buyable}
|
||||
if id == "free" {
|
||||
t.Price = Price{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestOffer(t *testing.T) {
|
||||
// held adds a held rung to a subscription on plus.
|
||||
held := func(mod func(*Position)) Position {
|
||||
p := holds("plus", true)
|
||||
p.Held = tierOf("standard")
|
||||
if mod != nil {
|
||||
mod(&p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
// below is a subscription on plus whose cancellation prediction exists.
|
||||
below := func(follows string, cancelScheduled bool) Position {
|
||||
p := holds("plus", true)
|
||||
p.Predicted = true
|
||||
p.Follows = follows
|
||||
p.CancelScheduled = cancelScheduled
|
||||
return p
|
||||
}
|
||||
with := func(tg Target, mod func(*Target)) Target {
|
||||
mod(&tg)
|
||||
return tg
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pos Position
|
||||
target Target
|
||||
kind Kind
|
||||
enabled bool
|
||||
reason Reason
|
||||
relation Relation
|
||||
held bool
|
||||
}{
|
||||
// What pays for the current rung, against a target above, below and equal.
|
||||
{name: "nothing held offers checkout of any tier", pos: holds("", false), target: target("basic"),
|
||||
kind: Checkout, enabled: true, relation: Available},
|
||||
{name: "a grant offers checkout above it", pos: holds("plus", false), target: target("pro"),
|
||||
kind: Checkout, enabled: true, relation: Upgrade},
|
||||
{name: "a grant offers nothing below it", pos: holds("plus", false), target: target("basic"),
|
||||
reason: BelowGrant, relation: Downgrade},
|
||||
{name: "a grant offers nothing on its own tier", pos: holds("plus", false), target: target("plus"),
|
||||
reason: Current},
|
||||
{name: "a subscription offers a switch above it", pos: holds("plus", true), target: target("pro"),
|
||||
kind: Switch, enabled: true, relation: Upgrade},
|
||||
{name: "a subscription offers a switch below it", pos: holds("plus", true), target: target("basic"),
|
||||
kind: Switch, enabled: true, relation: Downgrade},
|
||||
{name: "a subscription offers nothing on its own tier", pos: holds("plus", true), target: target("plus"),
|
||||
reason: Current},
|
||||
|
||||
// The held rung.
|
||||
{name: "the held rung below the current rung is a cancellation", pos: held(nil), target: target("standard"),
|
||||
kind: Cancel, enabled: true, relation: Downgrade, held: true},
|
||||
{name: "the held rung is a cancellation without a price", pos: held(nil), target: with(target("standard"), func(t *Target) { t.Price = Price{} }),
|
||||
kind: Cancel, enabled: true, relation: Downgrade, held: true},
|
||||
{name: "the held rung above the current rung offers nothing",
|
||||
pos: held(func(p *Position) { p.Held = tierOf("pro") }), target: target("pro"),
|
||||
reason: HeldAbove, relation: Upgrade, held: true},
|
||||
{name: "the held rung with the cancellation scheduled offers nothing",
|
||||
pos: held(func(p *Position) { p.CancelScheduled = true }), target: target("standard"),
|
||||
reason: HeldMoveMade, relation: Downgrade, held: true},
|
||||
{name: "a rung below the held rung and the current rung offers nothing", pos: held(nil), target: target("basic"),
|
||||
reason: BelowBoth, relation: Downgrade},
|
||||
{name: "a rung between the held rung and the current rung is a switch",
|
||||
pos: held(func(p *Position) { p.Held = tierOf("basic") }), target: target("standard"),
|
||||
kind: Switch, enabled: true, relation: Downgrade},
|
||||
|
||||
// A scheduled downgrade.
|
||||
{name: "a scheduled downgrade's target offers nothing",
|
||||
pos: held(func(p *Position) { p.Held = nil; p.ScheduledTo = "standard" }), target: target("standard"),
|
||||
reason: Scheduled, relation: Downgrade},
|
||||
{name: "a scheduled downgrade onto the held rung keeps the held move",
|
||||
pos: held(func(p *Position) { p.ScheduledTo = "standard" }), target: target("standard"),
|
||||
kind: Cancel, enabled: true, relation: Downgrade, held: true},
|
||||
{name: "a scheduled downgrade's target is a switch again once the cancellation is scheduled",
|
||||
pos: held(func(p *Position) { p.Held = nil; p.ScheduledTo = "standard"; p.CancelScheduled = true }), target: target("standard"),
|
||||
kind: Switch, enabled: true, relation: Downgrade},
|
||||
|
||||
// The unpriced rank-0 tier.
|
||||
{name: "the free rung is conferred when nothing is held", pos: holds("", false), target: target("free"),
|
||||
reason: Conferred, relation: Available},
|
||||
{name: "the free rung below a subscription whose prediction keeps it is a cancellation", pos: below("free", false), target: target("free"),
|
||||
kind: Cancel, enabled: true, relation: Downgrade},
|
||||
{name: "the free rung below a subscription whose prediction follows another product offers nothing", pos: below("basic", false), target: target("free"),
|
||||
reason: FreeRungNotFollowing, relation: Downgrade},
|
||||
{name: "the free rung below a subscription whose prediction follows nothing offers nothing", pos: below("", false), target: target("free"),
|
||||
reason: FreeRungNotFollowing, relation: Downgrade},
|
||||
{name: "the free rung below a scheduled cancellation offers nothing", pos: below("free", true), target: target("free"),
|
||||
reason: FreeRungNotFollowing, relation: Downgrade},
|
||||
{name: "the free rung below a subscription with no prediction is a cancellation", pos: holds("plus", true), target: target("free"),
|
||||
kind: Cancel, enabled: true, relation: Downgrade},
|
||||
{name: "the free rung below a grant offers nothing", pos: holds("plus", false), target: target("free"),
|
||||
reason: BelowGrant, relation: Downgrade},
|
||||
{name: "a priced rank-0 tier below a subscription is a switch", pos: holds("plus", true),
|
||||
target: with(target("free"), func(t *Target) { t.Price = buyable }),
|
||||
kind: Switch, enabled: true, relation: Downgrade},
|
||||
{name: "a priced rank-0 tier with nothing held is checkout", pos: holds("", false),
|
||||
target: with(target("free"), func(t *Target) { t.Price = buyable }),
|
||||
kind: Checkout, enabled: true, relation: Available},
|
||||
{name: "an unpriced tier above rank 0 is a disabled checkout", pos: holds("", false),
|
||||
target: with(target("basic"), func(t *Target) { t.Price = Price{} }),
|
||||
kind: Checkout, reason: NoPrice, relation: Available},
|
||||
{name: "an unpriced tier above rank 0 is a disabled switch", pos: holds("basic", true),
|
||||
target: with(target("plus"), func(t *Target) { t.Price = Price{} }),
|
||||
kind: Switch, reason: NoPrice, relation: Upgrade},
|
||||
|
||||
// The price rule.
|
||||
{name: "another price than the default offers nothing", pos: holds("", false),
|
||||
target: with(target("basic"), func(t *Target) { t.Price.Default = false }),
|
||||
reason: NotDefault, relation: Available},
|
||||
{name: "another price than the default offers no switch", pos: holds("basic", true),
|
||||
target: with(target("plus"), func(t *Target) { t.Price.Default = false }),
|
||||
reason: NotDefault, relation: Upgrade},
|
||||
{name: "a one-time price is a disabled checkout", pos: holds("", false),
|
||||
target: with(target("basic"), func(t *Target) { t.Price.Recurring = false }),
|
||||
kind: Checkout, reason: OneTime, relation: Available},
|
||||
{name: "a one-time price is a disabled switch", pos: holds("basic", true),
|
||||
target: with(target("plus"), func(t *Target) { t.Price.Recurring = false }),
|
||||
kind: Switch, reason: OneTime, relation: Upgrade},
|
||||
{name: "a price that is not purchasable is a disabled checkout", pos: holds("", false),
|
||||
target: with(target("basic"), func(t *Target) { t.Price.Purchasable = false }),
|
||||
kind: Checkout, reason: Unpurchasable, relation: Available},
|
||||
|
||||
// The member gate.
|
||||
{name: "a product outside the member gate is offered no checkout", pos: holds("", false),
|
||||
target: with(target("basic"), func(t *Target) { t.Listed = false }),
|
||||
reason: NotListed, relation: Available},
|
||||
{name: "a product outside the member gate is offered no switch", pos: holds("basic", true),
|
||||
target: with(target("plus"), func(t *Target) { t.Listed = false }),
|
||||
reason: NotListed, relation: Upgrade},
|
||||
{name: "the held rung outside the member gate is still a cancellation", pos: held(nil),
|
||||
target: with(target("standard"), func(t *Target) { t.Listed = false }),
|
||||
kind: Cancel, enabled: true, relation: Downgrade, held: true},
|
||||
|
||||
// Placement.
|
||||
{name: "a target that is not a tier of the ladder offers nothing", pos: holds("", false), target: target("elsewhere"),
|
||||
reason: NotOnLadder},
|
||||
{name: "a current product that is not a tier of the ladder offers nothing", pos: holds("elsewhere", true), target: target("plus"),
|
||||
reason: Unplaced},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Offer(tc.pos, tc.target)
|
||||
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held}
|
||||
if got != want {
|
||||
t.Errorf("Offer = %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcross(t *testing.T) {
|
||||
enabled := Move{Kind: Checkout, Enabled: true, Relation: Available}
|
||||
disabled := Move{Kind: Checkout, Reason: OneTime, Relation: Available}
|
||||
none := Move{Reason: BelowGrant, Relation: Downgrade}
|
||||
sw := Move{Kind: Switch, Enabled: true, Relation: Upgrade}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
moves []Move
|
||||
want Move
|
||||
}{
|
||||
{"no ladder", nil, Move{Reason: NotOnLadder}},
|
||||
{"one ladder", []Move{enabled}, enabled},
|
||||
{"every ladder offering an enabled checkout", []Move{enabled, enabled},
|
||||
Move{Kind: Checkout, Enabled: true}},
|
||||
{"one ladder offering checkout disabled", []Move{enabled, disabled},
|
||||
Move{Kind: Checkout, Reason: OneTime}},
|
||||
{"one ladder offering none", []Move{enabled, none}, Move{Reason: OtherLadder}},
|
||||
{"one ladder offering a switch", []Move{enabled, sw}, Move{Reason: OtherLadder}},
|
||||
{"a disabled checkout beside none", []Move{disabled, none}, Move{Reason: OtherLadder}},
|
||||
{"one lone ladder offering none keeps its own reason", []Move{none}, none},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Across(tc.moves); got != tc.want {
|
||||
t.Errorf("Across = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOffLadder(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*Target)
|
||||
want Move
|
||||
}{
|
||||
{"offered", func(*Target) {}, Move{Kind: Checkout, Enabled: true}},
|
||||
{"outside the member gate", func(t *Target) { t.Listed = false }, Move{Reason: NotListed}},
|
||||
{"no price", func(t *Target) { t.Price = Price{} }, Move{Kind: Checkout, Reason: NoPrice}},
|
||||
{"another price", func(t *Target) { t.Price.Default = false }, Move{Reason: NotDefault}},
|
||||
{"a one-time price", func(t *Target) { t.Price.Recurring = false }, Move{Kind: Checkout, Reason: OneTime}},
|
||||
{"not purchasable", func(t *Target) { t.Price.Purchasable = false }, Move{Kind: Checkout, Reason: Unpurchasable}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tg := target("addon")
|
||||
tc.edit(&tg)
|
||||
if got := OffLadder(tg); got != tc.want {
|
||||
t.Errorf("OffLadder = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveAllows(t *testing.T) {
|
||||
kinds := []Kind{None, Checkout, Switch, Cancel}
|
||||
for _, k := range []Kind{Checkout, Switch, Cancel} {
|
||||
on := Move{Kind: k, Enabled: true}
|
||||
off := Move{Kind: k}
|
||||
for _, other := range kinds {
|
||||
if got, want := on.Allows(other), other == k; got != want {
|
||||
t.Errorf("enabled %q move allows %q = %v, want %v", k, other, got, want)
|
||||
}
|
||||
if off.Allows(other) {
|
||||
t.Errorf("disabled %q move allows %q", k, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, other := range kinds {
|
||||
if (Move{}).Allows(other) {
|
||||
t.Errorf("None allows %q", other)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ internal/lint.Routes # the route table the forms registry test reads (permanent
|
||||
internal/lint.Route.IsMutation # same (permanent)
|
||||
|
||||
# Owned: removed by the step or issue named in the parentheses.
|
||||
internal/plans.* # the offer decision lands one commit before its callers (offer-enforcement section 2)
|
||||
internal/entitlements.RuleChangeCommit # the tests' entry point to the rule-change commit (#166 step 5)
|
||||
internal/entitlements.DrainObligation # the tests' entry point to the obligation drain (#166 step 5)
|
||||
internal/httplimit.IsTooLarge # no production caller yet (#146)
|
||||
|
||||
Reference in New Issue
Block a user