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:
2026-04-18 20:56:05 +08:00
parent 9ec9314aea
commit 63667ed2d1
28 changed files with 1738 additions and 80 deletions
@@ -53,6 +53,7 @@ SELECT p.plan_code, p.name AS plan_name, p.quota_policy_json,
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1 AND s.status = 'active' AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1
`
@@ -65,6 +65,7 @@ WITH plan AS (
WHERE s.tenant_id = $1
AND s.status = 'active'
AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1
)
UPDATE tenant_image_storage_usage u
@@ -45,6 +45,7 @@ SELECT p.plan_code, p.name AS plan_name, p.quota_policy_json,
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.status = 'active' AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1;
-- name: ListKolCardsForTenant :many
@@ -2,11 +2,23 @@ package repository
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ArticleQuotaStatus struct {
PlanCode string
PlanName string
Total int
Used int
Balance int
ResetAt *time.Time
}
type QuotaLedgerInput struct {
TenantID int64
OperatorID int64
@@ -30,6 +42,7 @@ type QuotaReservationInput struct {
type QuotaRepository interface {
GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error)
GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error)
InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error)
CreateQuotaReservation(ctx context.Context, input QuotaReservationInput) (int64, error)
UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error
@@ -38,22 +51,90 @@ type QuotaRepository interface {
}
type quotaRepository struct {
q generated.Querier
q generated.Querier
db generated.DBTX
}
func NewQuotaRepository(db generated.DBTX) QuotaRepository {
return &quotaRepository{q: newQuerier(db)}
return &quotaRepository{q: newQuerier(db), db: db}
}
func (r *quotaRepository) GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error) {
balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{
TenantID: tenantID,
QuotaType: quotaType,
})
if quotaType != "article_generation" {
balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{
TenantID: tenantID,
QuotaType: quotaType,
})
if err != nil {
return 0, err
}
return int(balance), nil
}
status, err := r.GetArticleQuotaStatus(ctx, tenantID, time.Now().UTC())
if err != nil {
return 0, err
}
return int(balance), nil
return status.Balance, nil
}
func (r *quotaRepository) GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error) {
access, err := loadTenantPlanAccess(ctx, r.db, tenantID, now)
if err != nil {
return nil, err
}
if access == nil {
return nil, pgx.ErrNoRows
}
// Non-HTTP callers (scheduler, workers) bypass subscription_guard, so any
// code path that consults article quota must itself enforce that the
// subscription is currently active. Return a zero-balance status when the
// plan is expired/inactive/not-yet-active so the caller short-circuits on
// insufficient balance instead of dispatching generation.
if !access.HasActiveAccess(now) {
return &ArticleQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: 0,
Used: 0,
Balance: 0,
}, nil
}
total := access.ArticleGenerationLimit()
if total <= 0 {
return &ArticleQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: 0,
Used: 0,
Balance: 0,
}, nil
}
windowStart, windowEnd, resetAt := access.ArticleQuotaWindow(now)
if windowEnd.IsZero() {
return nil, errors.New("article quota window end is missing")
}
used, err := CountEffectiveQuotaReservations(ctx, r.db, tenantID, "article_generation", windowStart, windowEnd)
if err != nil {
return nil, err
}
balance := total - used
if balance < 0 {
balance = 0
}
return &ArticleQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: total,
Used: used,
Balance: balance,
ResetAt: resetAt,
}, nil
}
func (r *quotaRepository) InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error) {
@@ -0,0 +1,326 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
const (
tenantPlanBlockedReasonRequired = "subscription_required"
tenantPlanBlockedReasonExpired = "trial_plan_expired"
tenantPlanBlockedReasonInactive = "subscription_inactive"
defaultArticleQuotaFallback = 0
)
type TenantPlanAccess struct {
SubscriptionID int64
PlanID int64
PlanCode string
PlanName string
PlanStatus string
SubscriptionStatus string
StartAt time.Time
EndAt time.Time
Policy sharedconfig.PlanQuotaPolicy
}
func (a *TenantPlanAccess) HasActiveAccess(now time.Time) bool {
if a == nil {
return false
}
if !strings.EqualFold(strings.TrimSpace(a.PlanStatus), "active") {
return false
}
if !strings.EqualFold(strings.TrimSpace(a.SubscriptionStatus), "active") {
return false
}
nowUTC := now.UTC()
if !a.StartAt.IsZero() && a.StartAt.After(nowUTC) {
return false
}
if a.EndAt.IsZero() {
return true
}
return a.EndAt.After(nowUTC)
}
func (a *TenantPlanAccess) BlockedReason(now time.Time) string {
if a == nil {
return tenantPlanBlockedReasonRequired
}
if a.HasActiveAccess(now) {
return ""
}
if strings.EqualFold(strings.TrimSpace(a.PlanCode), "free") && !a.EndAt.IsZero() && !a.EndAt.After(now.UTC()) {
return tenantPlanBlockedReasonExpired
}
return tenantPlanBlockedReasonInactive
}
func (a *TenantPlanAccess) ArticleGenerationLimit() int {
if a == nil {
return defaultArticleQuotaFallback
}
return maxInt(a.Policy.ArticleGeneration, 0)
}
func (a *TenantPlanAccess) BrandLimit() int {
if a == nil {
return 0
}
return maxInt(a.Policy.BrandLimit, 0)
}
func (a *TenantPlanAccess) ImageStorageBytes() int64 {
if a == nil {
return 0
}
return maxInt64(a.Policy.ImageStorageBytes, 0)
}
func (a *TenantPlanAccess) ArticleQuotaCycle() string {
if a == nil {
return sharedconfig.ArticleQuotaCycleLifetime
}
switch strings.ToLower(strings.TrimSpace(a.Policy.ArticleQuotaCycle)) {
case sharedconfig.ArticleQuotaCycleMonthly:
return sharedconfig.ArticleQuotaCycleMonthly
default:
return sharedconfig.ArticleQuotaCycleLifetime
}
}
func (a *TenantPlanAccess) ArticleQuotaWindow(now time.Time) (time.Time, time.Time, *time.Time) {
if a == nil {
return time.Time{}, time.Time{}, nil
}
windowStart := a.StartAt.UTC()
windowEnd := a.EndAt.UTC()
if a.ArticleQuotaCycle() != sharedconfig.ArticleQuotaCycleMonthly {
return windowStart, windowEnd, nil
}
cycleStart, cycleEnd := monthlyCycleBounds(windowStart, now.UTC())
if cycleStart.Before(windowStart) {
cycleStart = windowStart
}
if !windowEnd.IsZero() && cycleEnd.After(windowEnd) {
cycleEnd = windowEnd
}
resetAt := cycleEnd
return cycleStart, cycleEnd, &resetAt
}
type TenantPlanRepository interface {
UpsertConfiguredPlans(ctx context.Context, membership sharedconfig.MembershipConfig) error
GetTenantPlanAccess(ctx context.Context, tenantID int64, now time.Time) (*TenantPlanAccess, error)
}
type tenantPlanRepository struct {
db generated.DBTX
}
func NewTenantPlanRepository(db generated.DBTX) TenantPlanRepository {
return &tenantPlanRepository{db: db}
}
func (r *tenantPlanRepository) UpsertConfiguredPlans(ctx context.Context, membership sharedconfig.MembershipConfig) error {
for _, plan := range membership.Plans {
policyJSON, err := plan.QuotaPolicyJSON()
if err != nil {
return err
}
if _, err := r.db.Exec(ctx, `
INSERT INTO plans (plan_code, name, quota_policy_json, status)
VALUES ($1, $2, $3::jsonb, 'active')
ON CONFLICT (plan_code) WHERE deleted_at IS NULL
DO UPDATE SET
name = EXCLUDED.name,
quota_policy_json = EXCLUDED.quota_policy_json,
status = 'active',
updated_at = NOW()
`, plan.Code, plan.Name, policyJSON); err != nil {
return err
}
}
return nil
}
func (r *tenantPlanRepository) GetTenantPlanAccess(ctx context.Context, tenantID int64, now time.Time) (*TenantPlanAccess, error) {
return loadTenantPlanAccess(ctx, r.db, tenantID, now)
}
func loadTenantPlanAccess(ctx context.Context, db generated.DBTX, tenantID int64, now time.Time) (*TenantPlanAccess, error) {
row := db.QueryRow(ctx, `
SELECT
s.id,
s.plan_id,
p.plan_code,
p.name,
p.status,
s.status,
s.start_at,
s.end_at,
p.quota_policy_json
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.deleted_at IS NULL
AND p.deleted_at IS NULL
ORDER BY
CASE
WHEN s.status = 'active' AND p.status = 'active' AND s.start_at <= $2 AND s.end_at > $2 THEN 0
WHEN s.status = 'active' AND s.end_at <= $2 THEN 1
ELSE 2
END,
s.start_at DESC,
s.id DESC
LIMIT 1
`, tenantID, now.UTC())
var access TenantPlanAccess
var rawPolicy []byte
if err := row.Scan(
&access.SubscriptionID,
&access.PlanID,
&access.PlanCode,
&access.PlanName,
&access.PlanStatus,
&access.SubscriptionStatus,
&access.StartAt,
&access.EndAt,
&rawPolicy,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
access.StartAt = access.StartAt.UTC()
access.EndAt = access.EndAt.UTC()
access.Policy = sharedconfig.PlanQuotaPolicy{}
if len(rawPolicy) > 0 {
if err := json.Unmarshal(rawPolicy, &access.Policy); err != nil {
return nil, err
}
}
access.Policy.ArticleQuotaCycle = normalizeQuotaCycle(access.Policy.ArticleQuotaCycle, access.PlanCode)
return &access, nil
}
func CountEffectiveQuotaReservations(
ctx context.Context,
db generated.DBTX,
tenantID int64,
quotaType string,
startAt, endAt time.Time,
) (int, error) {
row := db.QueryRow(ctx, `
SELECT COALESCE(
SUM(
CASE
WHEN status IN ('pending', 'confirmed') THEN reserved_amount
ELSE 0
END
),
0
)::INT
FROM quota_reservations
WHERE tenant_id = $1
AND quota_type = $2
AND created_at >= $3
AND created_at < $4
`, tenantID, quotaType, startAt.UTC(), endAt.UTC())
var used int
if err := row.Scan(&used); err != nil {
return 0, err
}
return used, nil
}
func monthlyCycleBounds(anchor, now time.Time) (time.Time, time.Time) {
start := anchor.UTC()
current := now.UTC()
if current.Before(start) {
return start, addMonthsPreserveAnchor(start, 1)
}
months := (current.Year()-start.Year())*12 + int(current.Month()-start.Month())
cycleStart := addMonthsPreserveAnchor(start, months)
for cycleStart.After(current) {
months--
cycleStart = addMonthsPreserveAnchor(start, months)
}
cycleEnd := addMonthsPreserveAnchor(start, months+1)
for !cycleEnd.After(current) {
months++
cycleStart = addMonthsPreserveAnchor(start, months)
cycleEnd = addMonthsPreserveAnchor(start, months+1)
}
return cycleStart, cycleEnd
}
// addMonthsPreserveAnchor advances anchor by n months while clamping the
// resulting day to the target month's last day. This avoids Go's time.AddDate
// drift where e.g. Jan 31 + 1 month becomes Mar 3 instead of Feb 28.
func addMonthsPreserveAnchor(anchor time.Time, n int) time.Time {
if n == 0 {
return anchor
}
y := anchor.Year()
m := int(anchor.Month()) + n
for m > 12 {
y++
m -= 12
}
for m < 1 {
y--
m += 12
}
// Last day of the target month: day 0 of the following month.
lastDay := time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, anchor.Location()).Day()
day := anchor.Day()
if day > lastDay {
day = lastDay
}
return time.Date(y, time.Month(m), day, anchor.Hour(), anchor.Minute(), anchor.Second(), anchor.Nanosecond(), anchor.Location())
}
func normalizeQuotaCycle(value, planCode string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case sharedconfig.ArticleQuotaCycleMonthly:
return sharedconfig.ArticleQuotaCycleMonthly
default:
if strings.EqualFold(strings.TrimSpace(planCode), "free") {
return sharedconfig.ArticleQuotaCycleLifetime
}
return sharedconfig.ArticleQuotaCycleMonthly
}
}
func maxInt(value, fallback int) int {
if value < fallback {
return fallback
}
return value
}
func maxInt64(value, fallback int64) int64 {
if value < fallback {
return fallback
}
return value
}
@@ -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))
}
}