Go · 5152 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package profile_test
4
5 import (
6 "context"
7 "io"
8 "net/http"
9 "strconv"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/jackc/pgx/v5/pgtype"
15
16 billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/entitlements"
18 "github.com/tenseleyFlow/shithub/internal/infra/config"
19 )
20
21 // PRO07 — profile pin cap enforcement.
22 //
23 // The cap is resolved per-principal via entitlements (Free=6, Pro=100).
24 // Operators flip BillingEnforce.UserProfilePinsBeyondFree to make a
25 // Free user's 7th-pin attempt return 402 with an upgrade banner;
26 // otherwise the gate is report-only (logs the deny + returns the
27 // pre-PRO07 400 BadRequest).
28
29 func TestProfilePins_FreeUserBlockedAtCapWithEnforce(t *testing.T) {
30 t.Parallel()
31 env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
32 UserProfilePinsBeyondFree: true,
33 })
34 alice := env.insertUser(t, "freealice", "Alice", "")
35 repos := makeUserPinCandidates(t, env, alice.ID, 7)
36
37 resp := env.postPins(t, "/freealice/pins", alice, repos...)
38 if resp.StatusCode != http.StatusPaymentRequired {
39 body, _ := io.ReadAll(resp.Body)
40 t.Fatalf("Free user 7th pin: status=%d, want 402; body=%s", resp.StatusCode, body)
41 }
42 body, _ := io.ReadAll(resp.Body)
43 if !strings.Contains(string(body), "Pinned repositories") {
44 t.Errorf("upgrade banner missing feature label: %s", body)
45 }
46 }
47
48 func TestProfilePins_FreeUserReportOnlyWithoutEnforce(t *testing.T) {
49 t.Parallel()
50 env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
51 UserProfilePinsBeyondFree: false,
52 })
53 alice := env.insertUser(t, "softalice", "Alice", "")
54 repos := makeUserPinCandidates(t, env, alice.ID, 7)
55
56 resp := env.postPins(t, "/softalice/pins", alice, repos...)
57 // Report-only: the over-cap submission falls through to the same
58 // 400 the user got pre-PRO07. The would-deny lands in the logger.
59 if resp.StatusCode != http.StatusBadRequest {
60 t.Fatalf("report-only over-cap status=%d, want 400", resp.StatusCode)
61 }
62 }
63
64 func TestProfilePins_FreeUserUnderCapSavesWithEnforce(t *testing.T) {
65 t.Parallel()
66 env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
67 UserProfilePinsBeyondFree: true,
68 })
69 alice := env.insertUser(t, "fitalice", "Alice", "")
70 repos := makeUserPinCandidates(t, env, alice.ID, int(entitlements.FreeProfilePinsCap))
71
72 resp := env.postPins(t, "/fitalice/pins", alice, repos...)
73 if resp.StatusCode != http.StatusSeeOther {
74 body, _ := io.ReadAll(resp.Body)
75 t.Fatalf("Free user at-cap save: status=%d body=%s", resp.StatusCode, body)
76 }
77 }
78
79 func TestProfilePins_ProUserCanExceedFreeCap(t *testing.T) {
80 t.Parallel()
81 env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
82 UserProfilePinsBeyondFree: true,
83 })
84 alice := env.insertUser(t, "proalice", "Alice", "")
85 upgradeUserToProForTest(t, env, alice.ID)
86 repos := makeUserPinCandidates(t, env, alice.ID, int(entitlements.FreeProfilePinsCap)+3)
87
88 resp := env.postPins(t, "/proalice/pins", alice, repos...)
89 if resp.StatusCode != http.StatusSeeOther {
90 body, _ := io.ReadAll(resp.Body)
91 t.Fatalf("Pro user over-Free-cap save: status=%d body=%s", resp.StatusCode, body)
92 }
93 }
94
95 func TestProfilePins_ProUserBlockedAboveProCap(t *testing.T) {
96 t.Parallel()
97 env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
98 UserProfilePinsBeyondFree: true,
99 })
100 alice := env.insertUser(t, "manyalice", "Alice", "")
101 upgradeUserToProForTest(t, env, alice.ID)
102 repos := makeUserPinCandidates(t, env, alice.ID, int(entitlements.ProProfilePinsCap)+1)
103
104 resp := env.postPins(t, "/manyalice/pins", alice, repos...)
105 // Pro user over the Pro cap is a DB-sanity 400, NOT a 402 — the
106 // upgrade banner doesn't help here.
107 if resp.StatusCode != http.StatusBadRequest {
108 t.Fatalf("Pro user >100 pins: status=%d, want 400", resp.StatusCode)
109 }
110 }
111
112 // makeUserPinCandidates inserts `count` public user-owned repos and
113 // returns their IDs. Names are r0, r1, ... so the test can keep them
114 // distinct without colliding with any other fixture.
115 func makeUserPinCandidates(t *testing.T, env *profileEnv, userID int64, count int) []int64 {
116 t.Helper()
117 out := make([]int64, 0, count)
118 for i := 0; i < count; i++ {
119 id := env.insertUserRepo(t, userID, "r"+strconv.Itoa(i), "pin", "public", "Go", 0, 0)
120 out = append(out, id)
121 }
122 return out
123 }
124
125 func upgradeUserToProForTest(t *testing.T, env *profileEnv, userID int64) {
126 t.Helper()
127 now := time.Now().UTC()
128 suffix := strconv.FormatInt(userID, 10)
129 _, err := billingdb.New().ApplyUserSubscriptionSnapshot(context.Background(), env.pool, billingdb.ApplyUserSubscriptionSnapshotParams{
130 UserID: userID,
131 Plan: billingdb.UserPlanPro,
132 SubscriptionStatus: billingdb.BillingSubscriptionStatusActive,
133 StripeSubscriptionID: pgtype.Text{String: "sub_pin_pro_" + suffix, Valid: true},
134 CurrentPeriodStart: pgtype.Timestamptz{Time: now.Add(-time.Hour), Valid: true},
135 CurrentPeriodEnd: pgtype.Timestamptz{Time: now.Add(30 * 24 * time.Hour), Valid: true},
136 LastWebhookEventID: "evt_pin_pro_" + suffix,
137 })
138 if err != nil {
139 t.Fatalf("upgrade user to Pro: %v", err)
140 }
141 }
142