tenseleyflow/shithub / d2130f2

Browse files

billing: Principal{Kind, ID} type + validation

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d2130f2f8047da855057e8f8d0bf5774c5acf0b9
Parents
067862b
Tree
dd1d6c2

2 changed files

StatusFile+-
A internal/billing/principal.go 73 0
A internal/billing/principal_test.go 56 0
internal/billing/principal.goadded
@@ -0,0 +1,73 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package billing
4
+
5
+import (
6
+	"errors"
7
+	"fmt"
8
+	"strconv"
9
+)
10
+
11
+// Principal is the routing key for the polymorphic billing surface.
12
+// PRO02 Q3 ratified the abstraction: every billing service function
13
+// that takes a subject takes a Principal, and the service-layer
14
+// internals branch to the org or user sqlc query based on Kind.
15
+//
16
+// PRO04 lands the type and uses it internally — the existing
17
+// org-shaped service wrappers (`ApplySubscriptionSnapshot(...snap)`
18
+// where snap embeds OrgID) build a Principal{SubjectKindOrg,
19
+// snap.OrgID} and call the Principal-taking sibling. PRO05 sweeps
20
+// the handler-side gating call sites to use Principal directly.
21
+//
22
+// The zero value (`Principal{}`) is invalid by design: a routing
23
+// key that doesn't say where to route silently is a bug, not a
24
+// default.
25
+type Principal struct {
26
+	Kind SubjectKind
27
+	ID   int64
28
+}
29
+
30
+// ErrInvalidPrincipal surfaces from `Validate` and is returned by
31
+// any billing service function called with a zero-value or otherwise
32
+// malformed Principal. Routes that reach the database with a bad
33
+// Principal would surface a less-clear "invalid input value for
34
+// enum" error from postgres — checking up front is friendlier.
35
+var ErrInvalidPrincipal = errors.New("billing: invalid principal")
36
+
37
+// PrincipalForOrg is the canonical constructor for the org branch.
38
+// Equivalent to `Principal{Kind: SubjectKindOrg, ID: orgID}` but
39
+// the named constructor reads better at call sites and prevents
40
+// the kind/id field-order mix-up that a struct literal allows.
41
+func PrincipalForOrg(orgID int64) Principal {
42
+	return Principal{Kind: SubjectKindOrg, ID: orgID}
43
+}
44
+
45
+// PrincipalForUser is the user-side counterpart.
46
+func PrincipalForUser(userID int64) Principal {
47
+	return Principal{Kind: SubjectKindUser, ID: userID}
48
+}
49
+
50
+// Validate reports whether `p` is well-formed. Used at service-layer
51
+// entry points to surface bad routing keys with a clear error rather
52
+// than letting them reach the SQL layer.
53
+func (p Principal) Validate() error {
54
+	if !p.Kind.Valid() {
55
+		return fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
56
+	}
57
+	if p.ID <= 0 {
58
+		return fmt.Errorf("%w: id %d", ErrInvalidPrincipal, p.ID)
59
+	}
60
+	return nil
61
+}
62
+
63
+// IsOrg / IsUser are kind-narrowing predicates for call sites that
64
+// want a typed branch without comparing constants directly.
65
+func (p Principal) IsOrg() bool  { return p.Kind == SubjectKindOrg }
66
+func (p Principal) IsUser() bool { return p.Kind == SubjectKindUser }
67
+
68
+// String formats `<kind>:<id>` for log fields and error messages.
69
+// Matches the metadata-key convention used in Stripe events
70
+// (`shithub_subject_kind` + `shithub_subject_id`).
71
+func (p Principal) String() string {
72
+	return string(p.Kind) + ":" + strconv.FormatInt(p.ID, 10)
73
+}
internal/billing/principal_test.goadded
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package billing
4
+
5
+import (
6
+	"errors"
7
+	"testing"
8
+)
9
+
10
+func TestPrincipalValidate(t *testing.T) {
11
+	t.Parallel()
12
+	cases := []struct {
13
+		name string
14
+		p    Principal
15
+		want error
16
+	}{
17
+		{"zero value", Principal{}, ErrInvalidPrincipal},
18
+		{"zero kind", Principal{Kind: "", ID: 1}, ErrInvalidPrincipal},
19
+		{"bogus kind", Principal{Kind: "alien", ID: 1}, ErrInvalidPrincipal},
20
+		{"zero id", Principal{Kind: SubjectKindUser, ID: 0}, ErrInvalidPrincipal},
21
+		{"negative id", Principal{Kind: SubjectKindOrg, ID: -42}, ErrInvalidPrincipal},
22
+		{"valid user", PrincipalForUser(7), nil},
23
+		{"valid org", PrincipalForOrg(7), nil},
24
+	}
25
+	for _, tc := range cases {
26
+		t.Run(tc.name, func(t *testing.T) {
27
+			err := tc.p.Validate()
28
+			if tc.want == nil && err != nil {
29
+				t.Fatalf("unexpected error: %v", err)
30
+			}
31
+			if tc.want != nil && !errors.Is(err, tc.want) {
32
+				t.Fatalf("want %v, got %v", tc.want, err)
33
+			}
34
+		})
35
+	}
36
+}
37
+
38
+func TestPrincipalKindPredicates(t *testing.T) {
39
+	t.Parallel()
40
+	if !PrincipalForUser(1).IsUser() || PrincipalForUser(1).IsOrg() {
41
+		t.Errorf("PrincipalForUser predicates wrong")
42
+	}
43
+	if !PrincipalForOrg(1).IsOrg() || PrincipalForOrg(1).IsUser() {
44
+		t.Errorf("PrincipalForOrg predicates wrong")
45
+	}
46
+}
47
+
48
+func TestPrincipalString(t *testing.T) {
49
+	t.Parallel()
50
+	if got, want := PrincipalForUser(42).String(), "user:42"; got != want {
51
+		t.Errorf("user String: got %q, want %q", got, want)
52
+	}
53
+	if got, want := PrincipalForOrg(7).String(), "org:7"; got != want {
54
+		t.Errorf("org String: got %q, want %q", got, want)
55
+	}
56
+}