63667ed2d1
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.
264 lines
8.3 KiB
Go
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))
|
|
}
|
|
}
|