feat(membership): enforce tenant subscription plans with blocked UI
Add configurable membership plans (free/plus/pro) synced to DB on boot, a subscription guard middleware that blocks tenant endpoints on expired or missing plans, and a MembershipBlockedView that surfaces the reason so the admin can contact the tenant owner. Quota and brand-library reads now honor the active plan's policy JSON and expired subscriptions.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
)
|
||||
|
||||
func mustParseTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time %q: %v", value, err)
|
||||
}
|
||||
return parsed.UTC()
|
||||
}
|
||||
|
||||
func TestHasActiveAccess(t *testing.T) {
|
||||
now := mustParseTime(t, "2026-04-18T12:00:00Z")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
access *TenantPlanAccess
|
||||
want bool
|
||||
}{
|
||||
{name: "nil access", access: nil, want: false},
|
||||
{
|
||||
name: "plan inactive",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "inactive",
|
||||
SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-time.Hour),
|
||||
EndAt: now.Add(time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "subscription inactive",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "active",
|
||||
SubscriptionStatus: "cancelled",
|
||||
StartAt: now.Add(-time.Hour),
|
||||
EndAt: now.Add(time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "future start_at is blocked",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "active",
|
||||
SubscriptionStatus: "active",
|
||||
StartAt: now.Add(time.Hour),
|
||||
EndAt: now.Add(24 * time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "expired end_at",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "active",
|
||||
SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-48 * time.Hour),
|
||||
EndAt: now.Add(-time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "within window",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "active",
|
||||
SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-time.Hour),
|
||||
EndAt: now.Add(time.Hour),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero end_at is perpetual",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "active",
|
||||
SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-time.Hour),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.access.HasActiveAccess(now); got != tc.want {
|
||||
t.Fatalf("HasActiveAccess = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedReason(t *testing.T) {
|
||||
now := mustParseTime(t, "2026-04-18T12:00:00Z")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
access *TenantPlanAccess
|
||||
want string
|
||||
}{
|
||||
{name: "nil -> required", access: nil, want: "subscription_required"},
|
||||
{
|
||||
name: "active access -> empty",
|
||||
access: &TenantPlanAccess{
|
||||
PlanStatus: "active", SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "free expired -> trial_plan_expired",
|
||||
access: &TenantPlanAccess{
|
||||
PlanCode: "free", PlanStatus: "active", SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-96 * time.Hour), EndAt: now.Add(-time.Hour),
|
||||
},
|
||||
want: "trial_plan_expired",
|
||||
},
|
||||
{
|
||||
name: "paid expired -> inactive",
|
||||
access: &TenantPlanAccess{
|
||||
PlanCode: "pro", PlanStatus: "active", SubscriptionStatus: "active",
|
||||
StartAt: now.Add(-30 * 24 * time.Hour), EndAt: now.Add(-time.Hour),
|
||||
},
|
||||
want: "subscription_inactive",
|
||||
},
|
||||
{
|
||||
name: "future start_at -> inactive (not expired)",
|
||||
access: &TenantPlanAccess{
|
||||
PlanCode: "pro", PlanStatus: "active", SubscriptionStatus: "active",
|
||||
StartAt: now.Add(time.Hour), EndAt: now.Add(48 * time.Hour),
|
||||
},
|
||||
want: "subscription_inactive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.access.BlockedReason(now); got != tc.want {
|
||||
t.Fatalf("BlockedReason = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMonthsPreserveAnchor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
anchor string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{name: "jan 31 + 1 month clamps to feb 28 (non-leap)", anchor: "2026-01-31T09:00:00Z", n: 1, want: "2026-02-28T09:00:00Z"},
|
||||
{name: "jan 31 + 1 month clamps to feb 29 (leap)", anchor: "2024-01-31T09:00:00Z", n: 1, want: "2024-02-29T09:00:00Z"},
|
||||
{name: "jan 31 + 2 months -> mar 31", anchor: "2026-01-31T09:00:00Z", n: 2, want: "2026-03-31T09:00:00Z"},
|
||||
{name: "jan 30 + 1 month clamps to feb 28", anchor: "2026-01-30T09:00:00Z", n: 1, want: "2026-02-28T09:00:00Z"},
|
||||
{name: "mar 31 + 1 month clamps to apr 30", anchor: "2026-03-31T09:00:00Z", n: 1, want: "2026-04-30T09:00:00Z"},
|
||||
{name: "cross year", anchor: "2026-12-15T00:00:00Z", n: 2, want: "2027-02-15T00:00:00Z"},
|
||||
{name: "n=0 returns anchor", anchor: "2026-04-18T12:00:00Z", n: 0, want: "2026-04-18T12:00:00Z"},
|
||||
{name: "negative step backwards", anchor: "2026-03-31T09:00:00Z", n: -1, want: "2026-02-28T09:00:00Z"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
anchor := mustParseTime(t, tc.anchor)
|
||||
want := mustParseTime(t, tc.want)
|
||||
got := addMonthsPreserveAnchor(anchor, tc.n)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("addMonthsPreserveAnchor(%s, %d) = %s, want %s", tc.anchor, tc.n, got.Format(time.RFC3339), want.Format(time.RFC3339))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyCycleBoundsMonthEndAnchor(t *testing.T) {
|
||||
// Subscription anchored on Jan 31; current time inside Feb. Legacy
|
||||
// AddDate-based implementation drifted into March (Feb 31 -> Mar 3).
|
||||
anchor := mustParseTime(t, "2026-01-31T09:00:00Z")
|
||||
now := mustParseTime(t, "2026-02-15T00:00:00Z")
|
||||
|
||||
start, end := monthlyCycleBounds(anchor, now)
|
||||
|
||||
wantStart := mustParseTime(t, "2026-01-31T09:00:00Z")
|
||||
wantEnd := mustParseTime(t, "2026-02-28T09:00:00Z")
|
||||
|
||||
if !start.Equal(wantStart) {
|
||||
t.Fatalf("cycle start = %s, want %s", start.Format(time.RFC3339), wantStart.Format(time.RFC3339))
|
||||
}
|
||||
if !end.Equal(wantEnd) {
|
||||
t.Fatalf("cycle end = %s, want %s", end.Format(time.RFC3339), wantEnd.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyCycleBoundsContainsNowAcrossLeap(t *testing.T) {
|
||||
anchor := mustParseTime(t, "2024-01-31T00:00:00Z")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
now string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "late jan", now: "2024-01-31T12:00:00Z", wantStart: "2024-01-31T00:00:00Z", wantEnd: "2024-02-29T00:00:00Z"},
|
||||
{name: "mid feb leap", now: "2024-02-15T00:00:00Z", wantStart: "2024-01-31T00:00:00Z", wantEnd: "2024-02-29T00:00:00Z"},
|
||||
{name: "early mar", now: "2024-03-01T00:00:00Z", wantStart: "2024-02-29T00:00:00Z", wantEnd: "2024-03-31T00:00:00Z"},
|
||||
{name: "mid mar", now: "2024-03-15T00:00:00Z", wantStart: "2024-02-29T00:00:00Z", wantEnd: "2024-03-31T00:00:00Z"},
|
||||
{name: "early apr clamps to 30", now: "2024-04-15T00:00:00Z", wantStart: "2024-03-31T00:00:00Z", wantEnd: "2024-04-30T00:00:00Z"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
start, end := monthlyCycleBounds(anchor, mustParseTime(t, tc.now))
|
||||
if !start.Equal(mustParseTime(t, tc.wantStart)) {
|
||||
t.Fatalf("cycle start = %s, want %s", start.Format(time.RFC3339), tc.wantStart)
|
||||
}
|
||||
if !end.Equal(mustParseTime(t, tc.wantEnd)) {
|
||||
t.Fatalf("cycle end = %s, want %s", end.Format(time.RFC3339), tc.wantEnd)
|
||||
}
|
||||
// Invariant: now must fall within [start, end)
|
||||
now := mustParseTime(t, tc.now)
|
||||
if now.Before(start) || !now.Before(end) {
|
||||
t.Fatalf("now %s not within [%s, %s)", tc.now, start.Format(time.RFC3339), end.Format(time.RFC3339))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleQuotaWindowMonthlyRespectsSubscriptionBounds(t *testing.T) {
|
||||
anchor := mustParseTime(t, "2026-01-31T00:00:00Z")
|
||||
end := mustParseTime(t, "2027-01-31T00:00:00Z")
|
||||
access := &TenantPlanAccess{
|
||||
PlanCode: "pro",
|
||||
PlanStatus: "active",
|
||||
SubscriptionStatus: "active",
|
||||
StartAt: anchor,
|
||||
EndAt: end,
|
||||
Policy: sharedconfig.PlanQuotaPolicy{
|
||||
ArticleGeneration: 400,
|
||||
ArticleQuotaCycle: sharedconfig.ArticleQuotaCycleMonthly,
|
||||
BrandLimit: 2,
|
||||
ImageStorageBytes: 1024 * 1024 * 1024,
|
||||
},
|
||||
}
|
||||
|
||||
now := mustParseTime(t, "2026-02-15T00:00:00Z")
|
||||
start, cycleEnd, reset := access.ArticleQuotaWindow(now)
|
||||
|
||||
wantStart := mustParseTime(t, "2026-01-31T00:00:00Z")
|
||||
wantEnd := mustParseTime(t, "2026-02-28T00:00:00Z")
|
||||
if !start.Equal(wantStart) {
|
||||
t.Fatalf("window start = %s, want %s", start.Format(time.RFC3339), wantStart.Format(time.RFC3339))
|
||||
}
|
||||
if !cycleEnd.Equal(wantEnd) {
|
||||
t.Fatalf("window end = %s, want %s", cycleEnd.Format(time.RFC3339), wantEnd.Format(time.RFC3339))
|
||||
}
|
||||
if reset == nil || !reset.Equal(wantEnd) {
|
||||
t.Fatalf("reset = %v, want %s", reset, wantEnd.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user