Files
geo/server/internal/tenant/repository/tenant_plan_repo_test.go
T
root 63667ed2d1 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.
2026-04-18 20:56:05 +08:00

264 lines
8.3 KiB
Go

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))
}
}