tenseleyflow/shithub / 14f3043

Browse files

entitlements: add Pro v1 feature constants + profile-pin caps

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
14f3043007763deacb78ce7654f8ce305f12f046
Parents
77b25b6
Tree
443506d

3 changed files

StatusFile+-
M internal/entitlements/entitlements.go 49 0
M internal/entitlements/principal_test.go 19 0
A internal/entitlements/profile_pins_test.go 111 0
internal/entitlements/entitlements.gomodified
@@ -31,6 +31,20 @@ const (
3131
 	FeaturePrivateCollaboration     Feature = "private_collaboration_limit"
3232
 	FeatureStorageQuota             Feature = "storage_quota"
3333
 	FeatureActionsMinutesQuota      Feature = "actions_minutes_quota"
34
+	// PRO07 additions.
35
+	//
36
+	// FeatureProfilePinsBeyondFree gates raising the personal profile
37
+	// pin cap above the Free baseline. Free users get LimitProfilePinsFreeCap
38
+	// pins; Pro users get LimitProfilePinsProCap. PRO01 ratified this as
39
+	// a shithub-specific differentiator (gh does not gate it). Kinds:
40
+	// user only — orgs continue to share the visible Free cap.
41
+	FeatureProfilePinsBeyondFree Feature = "profile_pins_beyond_free"
42
+	// FeatureCodeOwnersReview is the placeholder hook for CODEOWNERS
43
+	// enforcement. Registered now so the gating call site can compile
44
+	// and the per-feature config knob exists; the underlying CODEOWNERS
45
+	// parser ships in a later sprint and only then does Allowed flip.
46
+	// PRO07 keeps the enforce path a no-op for both kinds. Kinds: user, org.
47
+	FeatureCodeOwnersReview Feature = "codeowners_review"
3448
 )
3549
 
3650
 // Deprecated aliases. Old call sites continue to compile; PRO05's
@@ -51,9 +65,26 @@ type Limit string
5165
 const (
5266
 	FreePrivateCollaborationLimit int64 = 3
5367
 
68
+	// FreeProfilePinsCap mirrors gh's visible profile pin cap. Ratified
69
+	// PRO01. Applies to Free users AND to all orgs — PRO07 leaves the
70
+	// org cap as a hard constant since pins are a user-tier differentiator.
71
+	FreeProfilePinsCap int64 = 6
72
+	// ProProfilePinsCap is "effectively unlimited" for product copy, but
73
+	// bounded for DB sanity per PRO01. A Pro user who pins 100 repos is
74
+	// the upper bound — beyond that the request errors at the cap.
75
+	ProProfilePinsCap int64 = 100
76
+
5477
 	LimitPrivateCollaboration Limit = "private_collaboration_limit"
5578
 	LimitStorageQuota         Limit = "storage_quota"
5679
 	LimitActionsMinutesQuota  Limit = "actions_minutes_quota"
80
+	// PRO07: profile-pin cap. Two limit keys are exposed so callers can
81
+	// ask either "what's my current cap" (Set.Limit resolves to free or
82
+	// pro based on plan) or "what's the absolute Free/Pro number." The
83
+	// usual handler-side path queries Set.Limit(LimitProfilePinsFreeCap)
84
+	// for the entitled cap; tests and the upgrade banner copy use the
85
+	// Pro variant.
86
+	LimitProfilePinsFreeCap Limit = "profile_pins_free_cap"
87
+	LimitProfilePinsProCap  Limit = "profile_pins_pro_cap"
5788
 )
5889
 
5990
 // Deprecated limit aliases. Same migration story as features.
@@ -81,6 +112,8 @@ var featureKinds = map[Feature][]billing.SubjectKind{
81112
 	FeaturePrivateCollaboration:     {billing.SubjectKindOrg},
82113
 	FeatureStorageQuota:             {billing.SubjectKindOrg}, // user pending SP08
83114
 	FeatureActionsMinutesQuota:      {billing.SubjectKindOrg}, // user pending SP08
115
+	FeatureProfilePinsBeyondFree:    {billing.SubjectKindUser},
116
+	FeatureCodeOwnersReview:         {billing.SubjectKindUser, billing.SubjectKindOrg},
84117
 }
85118
 
86119
 // AppliesTo reports the principal kinds a feature applies to.
@@ -267,6 +300,20 @@ func (s Set) Limit(name Limit) (LimitValue, error) {
267300
 		RequiredPlan: decision.RequiredPlan,
268301
 		Reason:       decision.Reason,
269302
 	}
303
+	// Profile pin caps are concrete on both sides of the gate: the Free
304
+	// cap and Pro cap are constants ratified by PRO01, so the value is
305
+	// always Defined regardless of the principal's plan. Handlers ask
306
+	// CanUse(FeatureProfilePinsBeyondFree) to pick which cap applies.
307
+	switch name {
308
+	case LimitProfilePinsFreeCap:
309
+		value.Defined = true
310
+		value.Value = FreeProfilePinsCap
311
+		return value, nil
312
+	case LimitProfilePinsProCap:
313
+		value.Defined = true
314
+		value.Value = ProProfilePinsCap
315
+		return value, nil
316
+	}
270317
 	if !decision.Allowed {
271318
 		return value, nil
272319
 	}
@@ -396,6 +443,8 @@ func limitFeature(name Limit) (Feature, string, bool) {
396443
 		return FeatureStorageQuota, "bytes", true
397444
 	case LimitActionsMinutesQuota:
398445
 		return FeatureActionsMinutesQuota, "minutes", true
446
+	case LimitProfilePinsFreeCap, LimitProfilePinsProCap:
447
+		return FeatureProfilePinsBeyondFree, "pins", true
399448
 	default:
400449
 		return "", "", false
401450
 	}
internal/entitlements/principal_test.gomodified
@@ -37,6 +37,7 @@ func TestAppliesToCrossKindFeatures(t *testing.T) {
3737
 	crossKind := []entitlements.Feature{
3838
 		entitlements.FeatureAdvancedBranchProtection,
3939
 		entitlements.FeatureRequiredReviewers,
40
+		entitlements.FeatureCodeOwnersReview,
4041
 	}
4142
 	for _, f := range crossKind {
4243
 		if !entitlements.FeatureAppliesToKind(f, billing.SubjectKindOrg) {
@@ -48,6 +49,24 @@ func TestAppliesToCrossKindFeatures(t *testing.T) {
4849
 	}
4950
 }
5051
 
52
+// TestAppliesToUserOnlyFeatures locks the PRO07-introduced user-only
53
+// features. FeatureProfilePinsBeyondFree must not be queryable for
54
+// org kind — orgs share the visible Free cap (PRO01 ratification).
55
+func TestAppliesToUserOnlyFeatures(t *testing.T) {
56
+	t.Parallel()
57
+	userOnly := []entitlements.Feature{
58
+		entitlements.FeatureProfilePinsBeyondFree,
59
+	}
60
+	for _, f := range userOnly {
61
+		if !entitlements.FeatureAppliesToKind(f, billing.SubjectKindUser) {
62
+			t.Errorf("%s should apply to user kind", f)
63
+		}
64
+		if entitlements.FeatureAppliesToKind(f, billing.SubjectKindOrg) {
65
+			t.Errorf("%s should NOT apply to org kind", f)
66
+		}
67
+	}
68
+}
69
+
5170
 func TestDeprecatedAliasesPointAtCanonical(t *testing.T) {
5271
 	t.Parallel()
5372
 	// SP-era constants are aliases for the renamed ones. Code that
internal/entitlements/profile_pins_test.goadded
@@ -0,0 +1,111 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package entitlements_test
4
+
5
+import (
6
+	"context"
7
+	"strconv"
8
+	"testing"
9
+	"time"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/billing"
15
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
17
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+)
20
+
21
+// TestProfilePinsCapConstantsAreDefinedForBothPlans locks the PRO01
22
+// ratification: LimitProfilePinsFreeCap = 6, LimitProfilePinsProCap = 100.
23
+// Both limits report Defined=true regardless of the principal's plan
24
+// — the cap is a constant, the *applicable* cap is whichever matches
25
+// CanUse(FeatureProfilePinsBeyondFree).
26
+func TestProfilePinsCapConstantsAreDefinedForBothPlans(t *testing.T) {
27
+	t.Parallel()
28
+	ctx := context.Background()
29
+	pool, userID := setupEntitlementUser(t, "freepin")
30
+
31
+	set, err := entitlements.ForPrincipal(ctx, entitlements.Deps{Pool: pool}, billing.PrincipalForUser(userID))
32
+	if err != nil {
33
+		t.Fatalf("ForPrincipal: %v", err)
34
+	}
35
+	free, err := set.Limit(entitlements.LimitProfilePinsFreeCap)
36
+	if err != nil {
37
+		t.Fatalf("Limit free: %v", err)
38
+	}
39
+	if !free.Defined || free.Value != entitlements.FreeProfilePinsCap {
40
+		t.Errorf("LimitProfilePinsFreeCap = %+v, want defined value 6", free)
41
+	}
42
+	pro, err := set.Limit(entitlements.LimitProfilePinsProCap)
43
+	if err != nil {
44
+		t.Fatalf("Limit pro: %v", err)
45
+	}
46
+	if !pro.Defined || pro.Value != entitlements.ProProfilePinsCap {
47
+		t.Errorf("LimitProfilePinsProCap = %+v, want defined value 100", pro)
48
+	}
49
+}
50
+
51
+func TestProfilePinsBeyondFreeFreeUserCannotUseFeature(t *testing.T) {
52
+	t.Parallel()
53
+	ctx := context.Background()
54
+	pool, userID := setupEntitlementUser(t, "freecap")
55
+	set, err := entitlements.ForPrincipal(ctx, entitlements.Deps{Pool: pool}, billing.PrincipalForUser(userID))
56
+	if err != nil {
57
+		t.Fatalf("ForPrincipal: %v", err)
58
+	}
59
+	decision := set.CanUse(entitlements.FeatureProfilePinsBeyondFree)
60
+	if decision.Allowed {
61
+		t.Errorf("Free user should NOT be allowed FeatureProfilePinsBeyondFree, got %+v", decision)
62
+	}
63
+}
64
+
65
+func TestProfilePinsBeyondFreeProUserCanUseFeature(t *testing.T) {
66
+	t.Parallel()
67
+	ctx := context.Background()
68
+	pool, userID := setupEntitlementUser(t, "prouser")
69
+	now := time.Now().UTC()
70
+	if err := upgradeUserToPro(ctx, pool, userID, now); err != nil {
71
+		t.Fatalf("upgrade to pro: %v", err)
72
+	}
73
+
74
+	set, err := entitlements.ForPrincipal(ctx, entitlements.Deps{
75
+		Pool: pool,
76
+		Now:  func() time.Time { return now },
77
+	}, billing.PrincipalForUser(userID))
78
+	if err != nil {
79
+		t.Fatalf("ForPrincipal: %v", err)
80
+	}
81
+	decision := set.CanUse(entitlements.FeatureProfilePinsBeyondFree)
82
+	if !decision.Allowed {
83
+		t.Errorf("Pro user should be allowed FeatureProfilePinsBeyondFree, got %+v", decision)
84
+	}
85
+}
86
+
87
+func setupEntitlementUser(t *testing.T, username string) (*pgxpool.Pool, int64) {
88
+	t.Helper()
89
+	pool := dbtest.NewTestDB(t)
90
+	user, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
91
+		Username: username, DisplayName: username, PasswordHash: fixtureHash,
92
+	})
93
+	if err != nil {
94
+		t.Fatalf("CreateUser: %v", err)
95
+	}
96
+	return pool, user.ID
97
+}
98
+
99
+func upgradeUserToPro(ctx context.Context, pool *pgxpool.Pool, userID int64, now time.Time) error {
100
+	suffix := strconv.FormatInt(userID, 10)
101
+	_, err := billingdb.New().ApplyUserSubscriptionSnapshot(ctx, pool, billingdb.ApplyUserSubscriptionSnapshotParams{
102
+		UserID:               userID,
103
+		Plan:                 billingdb.UserPlanPro,
104
+		SubscriptionStatus:   billingdb.BillingSubscriptionStatusActive,
105
+		StripeSubscriptionID: pgtype.Text{String: "sub_pro_" + suffix, Valid: true},
106
+		CurrentPeriodStart:   pgtype.Timestamptz{Time: now.Add(-time.Hour), Valid: true},
107
+		CurrentPeriodEnd:     pgtype.Timestamptz{Time: now.Add(30 * 24 * time.Hour), Valid: true},
108
+		LastWebhookEventID:   "evt_pro_" + suffix,
109
+	})
110
+	return err
111
+}