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