Skip to content

Commit 4fa1937

Browse files
test(coverage/plans): drive plans to ≥95% (was 82.5%)
Cover defensive nil-plan branches, parse() empty-plans guard, ValidatePromotion expiry/skip paths, TeamMemberLimit tier defaults, and QueueCountLimit zero-fallback. White-box tests in package plans build Registry directly to reach branches Get()'s anonymous fallback hides from black-box callers. Module coverage: 82.5% → 99.3%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5cb47ae commit 4fa1937

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

plans/plans_coverage_test.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// plans_coverage_test.go — white-box tests that cover branches unreachable
2+
// through the public API (defensive nil-checks behind Registry.Get fallback,
3+
// the parse() empty-plans guard, ValidatePromotion expiry/continue branches,
4+
// and TeamMemberLimit per-tier defaults). Keeps the `plans` package over 95%
5+
// line coverage without weakening the public contract.
6+
7+
package plans
8+
9+
import (
10+
"strings"
11+
"testing"
12+
"time"
13+
)
14+
15+
// emptyRegistry builds a Registry with no plans. Used to exercise the
16+
// defensive `if p == nil` branches on every accessor; in production these
17+
// are unreachable because parse() rejects YAML without an anonymous plan.
18+
func emptyRegistry() *Registry {
19+
return &Registry{plans: map[string]*Plan{}, promotions: nil}
20+
}
21+
22+
// TestParse_EmptyPlansMap covers the `len(raw.Plans) == 0` guard at
23+
// plans.go:217. A YAML document that defines no plans must be rejected
24+
// before the anonymous-plan check fires.
25+
func TestParse_EmptyPlansMap(t *testing.T) {
26+
cases := []string{
27+
// Completely empty document.
28+
"",
29+
// Explicit empty plans map.
30+
"plans: {}\n",
31+
// Only promotions, no plans.
32+
"promotions: []\n",
33+
}
34+
for _, in := range cases {
35+
_, err := parse([]byte(in))
36+
if err == nil {
37+
t.Errorf("parse(%q) = nil error, want non-nil for empty plans", in)
38+
continue
39+
}
40+
if !strings.Contains(err.Error(), "no plans") {
41+
t.Errorf("parse(%q) err = %v, want 'no plans' message", in, err)
42+
}
43+
}
44+
}
45+
46+
// TestValidatePromotion_SkipsNonMatchingCode covers the `continue` at
47+
// plans.go:266 — when the first promotion's code doesn't match the request,
48+
// the loop must continue to the next entry rather than fall through.
49+
func TestValidatePromotion_SkipsNonMatchingCode(t *testing.T) {
50+
r := &Registry{
51+
plans: map[string]*Plan{"anonymous": {Name: "anonymous"}, "pro": {Name: "pro"}},
52+
promotions: []Promotion{
53+
{Code: "FIRST", DiscountPercent: 10, AppliesTo: []string{"pro"}, MaxUses: -1},
54+
{Code: "SECOND", DiscountPercent: 25, AppliesTo: []string{"pro"}, MaxUses: -1},
55+
},
56+
}
57+
promo, err := r.ValidatePromotion("SECOND", "pro")
58+
if err != nil {
59+
t.Fatalf("ValidatePromotion(SECOND, pro) err = %v, want nil", err)
60+
}
61+
if promo.DiscountPercent != 25 {
62+
t.Errorf("got discount %d, want 25 — loop must have skipped FIRST", promo.DiscountPercent)
63+
}
64+
}
65+
66+
// TestValidatePromotion_ExpiredCode covers the expiry-parse branch at
67+
// plans.go:270-274. An expired promotion must return an error mentioning
68+
// expiration.
69+
func TestValidatePromotion_ExpiredCode(t *testing.T) {
70+
// Yesterday in UTC, formatted as YYYY-MM-DD.
71+
yesterday := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
72+
r := &Registry{
73+
plans: map[string]*Plan{"anonymous": {Name: "anonymous"}, "pro": {Name: "pro"}},
74+
promotions: []Promotion{
75+
{Code: "EXPIRED", DiscountPercent: 50, AppliesTo: []string{"pro"}, ExpiresAt: yesterday, MaxUses: -1},
76+
},
77+
}
78+
_, err := r.ValidatePromotion("EXPIRED", "pro")
79+
if err == nil {
80+
t.Fatal("ValidatePromotion(EXPIRED, pro) err = nil, want expired error")
81+
}
82+
if !strings.Contains(err.Error(), "expired") {
83+
t.Errorf("err = %v, want substring 'expired'", err)
84+
}
85+
}
86+
87+
// TestValidatePromotion_FutureExpiry covers the "expiry parsed but not yet
88+
// reached" path — the time.Parse succeeds and time.Now().After returns false,
89+
// so the promotion is returned. Distinct from the expired path above.
90+
func TestValidatePromotion_FutureExpiry(t *testing.T) {
91+
tomorrow := time.Now().UTC().AddDate(0, 0, 2).Format("2006-01-02")
92+
r := &Registry{
93+
plans: map[string]*Plan{"anonymous": {Name: "anonymous"}, "pro": {Name: "pro"}},
94+
promotions: []Promotion{
95+
{Code: "LATER", DiscountPercent: 30, AppliesTo: []string{"pro"}, ExpiresAt: tomorrow, MaxUses: -1},
96+
},
97+
}
98+
promo, err := r.ValidatePromotion("LATER", "pro")
99+
if err != nil {
100+
t.Fatalf("ValidatePromotion(LATER, pro) err = %v, want nil", err)
101+
}
102+
if promo.DiscountPercent != 30 {
103+
t.Errorf("got %d, want 30", promo.DiscountPercent)
104+
}
105+
}
106+
107+
// TestValidatePromotion_UnparseableExpiry covers the silent-skip behaviour
108+
// when ExpiresAt fails to parse — the entry is treated as never-expiring.
109+
func TestValidatePromotion_UnparseableExpiry(t *testing.T) {
110+
r := &Registry{
111+
plans: map[string]*Plan{"anonymous": {Name: "anonymous"}, "pro": {Name: "pro"}},
112+
promotions: []Promotion{
113+
{Code: "GARBAGE", DiscountPercent: 5, AppliesTo: []string{"pro"}, ExpiresAt: "not-a-date", MaxUses: -1},
114+
},
115+
}
116+
_, err := r.ValidatePromotion("GARBAGE", "pro")
117+
if err != nil {
118+
t.Errorf("unparseable expiry should be treated as never-expires; got err = %v", err)
119+
}
120+
}
121+
122+
// TestTeamMemberLimit_AllTierDefaults exercises every branch of the
123+
// fallback-default switch at plans.go:340-349. Each case is hit by a Plan
124+
// whose TeamMembers field is 0 (i.e. unset in YAML).
125+
func TestTeamMemberLimit_AllTierDefaults(t *testing.T) {
126+
r := &Registry{plans: map[string]*Plan{
127+
"anonymous": {Name: "anonymous"},
128+
"team": {Name: "team"},
129+
"pro": {Name: "pro"},
130+
"growth": {Name: "growth"},
131+
"hobby": {Name: "hobby"}, // hits the default branch (= 1)
132+
"weird": {Name: "weird"}, // unknown tier → Get falls back to anonymous → 1
133+
}}
134+
cases := map[string]int{
135+
"team": -1, // unlimited
136+
"pro": 5,
137+
"growth": 10,
138+
"hobby": 1, // default branch
139+
"anonymous": 1, // default branch
140+
}
141+
for tier, want := range cases {
142+
if got := r.TeamMemberLimit(tier); got != want {
143+
t.Errorf("TeamMemberLimit(%q) = %d, want %d", tier, got, want)
144+
}
145+
}
146+
}
147+
148+
// TestTeamMemberLimit_ExplicitYAMLOverride covers the early-return at
149+
// plans.go:337 — a non-zero TeamMembers value short-circuits the default
150+
// switch.
151+
func TestTeamMemberLimit_ExplicitYAMLOverride(t *testing.T) {
152+
r := &Registry{plans: map[string]*Plan{
153+
"anonymous": {Name: "anonymous"},
154+
"pro": {Name: "pro", Limits: Limits{TeamMembers: 7}},
155+
}}
156+
if got := r.TeamMemberLimit("pro"); got != 7 {
157+
t.Errorf("TeamMemberLimit(pro) = %d, want 7 (explicit YAML override)", got)
158+
}
159+
}
160+
161+
// TestQueueCountLimit_ZeroFallback covers the `QueueCount == 0` branch at
162+
// plans.go:489-491 — an older YAML with the field absent must return -1
163+
// (unlimited) so existing customers don't get blocked.
164+
func TestQueueCountLimit_ZeroFallback(t *testing.T) {
165+
r := &Registry{plans: map[string]*Plan{
166+
"anonymous": {Name: "anonymous"},
167+
"legacy": {Name: "legacy"}, // QueueCount defaults to 0
168+
}}
169+
if got := r.QueueCountLimit("legacy"); got != -1 {
170+
t.Errorf("QueueCountLimit(legacy) = %d, want -1 (zero-fallback)", got)
171+
}
172+
}
173+
174+
// TestAccessors_NilPlanBranches exercises the defensive `if p == nil`
175+
// branches that are unreachable through the public API (because Get()
176+
// returns the anonymous fallback). Constructed via an empty Registry so
177+
// every accessor's p==nil path is hit.
178+
func TestAccessors_NilPlanBranches(t *testing.T) {
179+
r := emptyRegistry()
180+
181+
if got := r.BillingPeriod("anything"); got != "monthly" {
182+
t.Errorf("BillingPeriod(nil) = %q, want 'monthly'", got)
183+
}
184+
if got := r.CustomDomainsMaxLimit("anything"); got != 0 {
185+
t.Errorf("CustomDomainsMaxLimit(nil) = %d, want 0", got)
186+
}
187+
if got := r.VaultMaxEntries("anything"); got != 0 {
188+
t.Errorf("VaultMaxEntries(nil) = %d, want 0", got)
189+
}
190+
envs := r.VaultEnvsAllowed("anything")
191+
if envs == nil || len(envs) != 0 {
192+
t.Errorf("VaultEnvsAllowed(nil) = %v, want empty non-nil slice", envs)
193+
}
194+
if got := r.DeploymentsAppsLimit("anything"); got != -1 {
195+
t.Errorf("DeploymentsAppsLimit(nil) = %d, want -1", got)
196+
}
197+
if got := r.QueueCountLimit("anything"); got != -1 {
198+
t.Errorf("QueueCountLimit(nil) = %d, want -1", got)
199+
}
200+
if got := r.BackupRetentionDays("anything"); got != 0 {
201+
t.Errorf("BackupRetentionDays(nil) = %d, want 0", got)
202+
}
203+
if r.BackupRestoreEnabled("anything") {
204+
t.Error("BackupRestoreEnabled(nil) = true, want false")
205+
}
206+
if got := r.ManualBackupsPerDay("anything"); got != 0 {
207+
t.Errorf("ManualBackupsPerDay(nil) = %d, want 0", got)
208+
}
209+
if got := r.RPOMinutes("anything"); got != 0 {
210+
t.Errorf("RPOMinutes(nil) = %d, want 0", got)
211+
}
212+
if got := r.RTOMinutes("anything"); got != 0 {
213+
t.Errorf("RTOMinutes(nil) = %d, want 0", got)
214+
}
215+
}
216+
217+
// TestVaultEnvsAllowed_NilSliceInPlan covers the `p.Limits.VaultEnvsAllowed
218+
// == nil` branch at plans.go:452 — a plan with the field unset must surface
219+
// an empty slice (not nil) so callers can range over it safely.
220+
func TestVaultEnvsAllowed_NilSliceInPlan(t *testing.T) {
221+
r := &Registry{plans: map[string]*Plan{
222+
"anonymous": {Name: "anonymous"},
223+
"weird": {Name: "weird"}, // VaultEnvsAllowed defaults to nil
224+
}}
225+
envs := r.VaultEnvsAllowed("weird")
226+
if envs == nil {
227+
t.Error("VaultEnvsAllowed returned nil, want empty slice")
228+
}
229+
if len(envs) != 0 {
230+
t.Errorf("VaultEnvsAllowed = %v, want empty slice", envs)
231+
}
232+
}
233+
234+
// TestDefault_DoesNotPanic guards plans.go:551 — Default() must succeed on
235+
// the embedded defaultYAML. If defaultYAML ever drifts to invalid syntax,
236+
// this fails before the panic.
237+
func TestDefault_DoesNotPanic(t *testing.T) {
238+
defer func() {
239+
if r := recover(); r != nil {
240+
t.Fatalf("Default() panicked: %v", r)
241+
}
242+
}()
243+
r := Default()
244+
if r == nil {
245+
t.Fatal("Default() returned nil")
246+
}
247+
if len(r.plans) == 0 {
248+
t.Fatal("Default() returned empty registry")
249+
}
250+
}

0 commit comments

Comments
 (0)