Files
3x-ui/internal/web/service/inbound_autorenew_calendar_test.go
T
n0ctal b8903fadf4 feat(clients): renew on a calendar day instead of a rolling interval (#6239)
* feat(clients): renew on a calendar day instead of a rolling interval

Auto-renew advances the expiry by a fixed number of milliseconds, so a client
set to 30 days drifts against the calendar: renewing on 31 January lands on
2 March, and by the end of the year the billing day has wandered a fortnight
from where the operator's own plan resets.

Add a per-client renewal day. When set, the expiry steps whole calendar months
at midnight in the panel's time zone. A month too short for the chosen day
renews on its last day and the following month returns to the chosen one, so
the 31st does not decay into the 28th permanently.

Zero keeps the interval mode, so existing clients are untouched.

The interval branch now also refuses a zero step. It is unreachable while the
selection filter holds, but that loop runs on the single traffic writer, and a
zero interval there would hang every panel mutation behind it.

* fix(clients): persist the calendar renewal day on the client record

resetDay lived only in the inbound settings JSON and client_traffics, so
every path that rebuilds a client from the clients table wrote it back as
zero: an ordinary edit, an attach to a second inbound, a traffic reset on
a disabled client. Calendar mode turned itself off during normal use and
the operator only found out a month later.

Adds reset_day to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge and the record update map, so the value survives
the round trip. The clients page filter and ClientSlim now recognise the
mode, nodeClientRenewed classifies a calendar renewal as a renewal, the
node snapshot merge carries reset_day, and the service layer rejects a
day outside 0-31 rather than clamping it silently.

Also renames the label keys to renewOnDay to keep them apart from the
existing renewDays, translates them and the new RESET_DAY subscription
placeholder in all 13 locales, adds the field to the bulk-add modal, and
drops the stray internal/web/dist/.gitkeep build stub.

* fix(clients): let the billing day be changed after creation

ClientService.Update writes the record columns directly only for a client
with no inbounds. The normal path goes through SyncInbound and
applyClientRecordMerge, which this change had not extended, so moving a
client from the 20th to the 5th updated the inbound settings JSON while
clients.reset_day kept the old value and the renewal kept using it.

The existing test did not catch it: it asserted the day survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheBillingDay moves the day and then
switches calendar mode off again; removing the record write turns it red.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-18 12:46:00 +02:00

392 lines
14 KiB
Go

package service
import (
"encoding/json"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// pinPanelZone fixes the panel time zone so the assertions below can talk about
// calendar days without the test machine's own zone shifting them.
func pinPanelZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Skipf("zone database unavailable: %v", err)
}
if err := database.GetDB().Create(&model.Setting{Key: "timeLocation", Value: name}).Error; err != nil {
t.Fatalf("pin panel zone: %v", err)
}
return loc
}
// Calendar mode renews on the same day each month. The interval mode drifts —
// 30 days from 31 January is 2 March — which is the whole reason for the mode.
func TestAutoRenewClients_CalendarModeLandsOnTheBillingDay(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
zone := pinPanelZone(t, "UTC")
// Expired two calendar months ago, billed on the 15th.
past := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC)
clients := []model.Client{
{Email: "cal@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, ResetDay: 15, ExpiryTime: past.UnixMilli()},
}
ib := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "cal@x", Enable: false, Up: 5, Down: 6,
ResetDay: 15, ExpiryTime: past.UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "cal@x").First(&row).Error; err != nil {
t.Fatal(err)
}
got := time.UnixMilli(row.ExpiryTime).In(zone)
if got.Day() != 15 {
t.Fatalf("renewed to %s, want the 15th: calendar mode must not drift", got.Format(time.RFC3339))
}
if !got.After(time.Now()) {
t.Fatalf("renewed to %s, which is not in the future", got.Format(time.RFC3339))
}
if h, m, s := got.Clock(); h != 0 || m != 0 || s != 0 {
t.Fatalf("renewed to %02d:%02d:%02d, want midnight", h, m, s)
}
if row.Up != 0 || row.Down != 0 {
t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down)
}
if !row.Enable {
t.Fatal("a renewed client must be re-enabled")
}
}
// A client billed on the 31st keeps that day, borrowing the last day only in
// months that are too short for it.
func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
zone := pinPanelZone(t, "UTC")
past := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
clients := []model.Client{
{Email: "eom@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli()},
}
ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "eom@x", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "eom@x").First(&row).Error; err != nil {
t.Fatal(err)
}
got := time.UnixMilli(row.ExpiryTime).In(zone)
want := firstBillingMidnightAfter(t, time.Now().In(zone), 31, zone)
if !got.Equal(want) {
t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
// Deliberately not built on nextCalendarRenewal: it walks a day at a time and
// derives month length from time.Date's own zero-day trick, so it can disagree.
func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time {
t.Helper()
cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
for i := 0; i < 400; i++ {
cur = cur.AddDate(0, 0, 1)
want := day
if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last {
want = last
}
if cur.Day() == want {
return cur
}
}
t.Fatalf("no billing midnight for day %d within a year of %s", day, from)
return time.Time{}
}
// Interval clients must be untouched by the new field.
func TestAutoRenewClients_IntervalModeUnchanged(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "days@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ExpiryTime: past},
}
ib := mkInbound(t, 30203, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "days@x", Enable: false, Reset: 30, ExpiryTime: past,
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "days@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if want := past + 30*86400000; row.ExpiryTime != want {
t.Fatalf("interval renewal moved to %d, want the old fixed step %d", row.ExpiryTime, want)
}
}
// The selection filter is what keeps a row with neither mode configured out of
// the renewal loop. That matters more than it looks: the interval step is
// reset*24h, so a zero interval reaching that loop would spin forever on the
// single traffic writer. The guard in the loop is a second line of defence and
// is deliberately unreachable while this filter holds.
func TestAutoRenewClients_RowWithNoModeIsNotSelected(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "none@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, ExpiryTime: past},
}
ib := mkInbound(t, 30204, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
// Seeded straight into the table with both modes off, the shape the
// selection filter is supposed to exclude.
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "none@x", Enable: false, Reset: 0, ResetDay: 0, ExpiryTime: past,
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
// Asserted against the query rather than by running the loop: the failure
// this guards is a hang, and a stuck goroutine outlives the test's DB.
var selected int64
if err := db.Model(&xray.ClientTraffic{}).
Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", time.Now().UnixMilli()).
Where("email = ?", "none@x").
Count(&selected).Error; err != nil {
t.Fatal(err)
}
if selected != 0 {
t.Fatal("a row with no renewal mode was selected for renewal: it would reach the interval loop and spin forever")
}
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 0 {
t.Fatalf("renewed count = %d, want 0", count)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "none@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.ExpiryTime != past {
t.Fatalf("a client with no renewal mode was renewed to %d", row.ExpiryTime)
}
}
// The billing day has to survive the clients table, not just the settings JSON:
// an ordinary edit rebuilds the client from the record and writes it back (#6106).
func TestClientEditKeepsTheBillingDay(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
clients := []model.Client{
{Email: "keep@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, ResetDay: 20, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
}
ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
mkTraffic(t, ib.Id, "keep@x", 10, 20, 0, 0, true)
rec, err := svc.clientService.GetRecordByEmail(nil, "keep@x")
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.ResetDay != 20 {
t.Fatalf("clients.reset_day = %d, want the 20 the client was created with", rec.ResetDay)
}
// What the edit dialog does: hydrate the record, change something else, save.
edited := rec.ToClient()
edited.Comment = "renamed"
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update: %v", err)
}
// The inbound JSON is what xray and the edit dialog read back, and it is
// rebuilt from the record, so it is where a dropped converter field shows.
var stored model.Inbound
if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
t.Fatalf("parse inbound settings: %v", err)
}
if len(settings.Clients) != 1 {
t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
}
if settings.Clients[0].ResetDay != 20 {
t.Fatalf("inbound settings resetDay = %d after an unrelated edit, want 20: calendar mode was silently turned off", settings.Clients[0].ResetDay)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "keep@x")
if err != nil {
t.Fatalf("GetRecordByEmail after edit: %v", err)
}
if rec.ResetDay != 20 {
t.Fatalf("clients.reset_day = %d after an unrelated edit, want 20", rec.ResetDay)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "keep@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.ResetDay != 20 {
t.Fatalf("client_traffics.reset_day = %d after an unrelated edit, want 20", row.ResetDay)
}
}
// The billing day is useless if it can only be chosen once. The test above
// passes even without the record write, because nothing overwrites the value
// it checks; this one fails without it.
func TestClientEditChangesTheBillingDay(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
clients := []model.Client{
{
Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, ResetDay: 20,
ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
},
}
ib := mkInbound(t, 30206, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true)
rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
edited := rec.ToClient()
edited.ResetDay = 5
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update: %v", err)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail after edit: %v", err)
}
if rec.ResetDay != 5 {
t.Fatalf("clients.reset_day = %d after the operator moved the billing day to the 5th", rec.ResetDay)
}
// Turning calendar mode off has to work too.
edited = rec.ToClient()
edited.ResetDay = 0
edited.Reset = 30
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update back to interval mode: %v", err)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail after switching mode: %v", err)
}
if rec.ResetDay != 0 {
t.Fatalf("clients.reset_day = %d after the operator switched back to interval mode", rec.ResetDay)
}
}
// The two renewal features meet here: a calendar client is capped like an
// interval one, spending one allowance per month rather than per tick.
func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
zone := pinPanelZone(t, "UTC")
// Three calendar months behind with one allowance left: a single month step
// cannot reach the present, so the client stays expired on its billing day.
past := time.Now().In(zone).AddDate(0, -3, 0)
past = time.Date(past.Year(), past.Month(), 10, 0, 0, 0, 0, zone)
clients := []model.Client{
{Email: "calcap@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 10, ResetMax: 3, ExpiryTime: past.UnixMilli()},
}
ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "calcap@x", Enable: false, ResetDay: 10, ResetMax: 3, ResetCount: 2,
Up: 111, Down: 222, ExpiryTime: past.UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "calcap@x").First(&row).Error; err != nil {
t.Fatal(err)
}
got := time.UnixMilli(row.ExpiryTime).In(zone)
if want := past.AddDate(0, 1, 0); !got.Equal(want) {
t.Fatalf("renewed to %s, want exactly one month on to %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
}
if row.ResetCount != 3 {
t.Fatalf("resetCount = %d, want 3: one allowance per month stepped", row.ResetCount)
}
if row.Enable {
t.Fatal("a client still expired after a truncated catch-up was enabled")
}
if row.Up != 111 || row.Down != 222 {
t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down)
}
}