feat(panel-go): monthly traffic reset for customers

- trafficResetDay/Period/At on the shared customer record (omitempty,
  inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
  idempotency, short-month clamping (31st fires Feb 28), missed-period
  catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
  API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
  full Go/Node suites green, difftest formal 50/50
This commit is contained in:
aaaamoooo
2026-10-20 15:10:13 +00:00
parent f4ef3d96a6
commit 7c21742810
7 changed files with 744 additions and 1 deletions
+22
View File
@@ -1652,6 +1652,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil {
return nil, err
}
resetDay, err := ParseResetDay(body["trafficResetDay"])
if err != nil {
return nil, err
}
next := &Customer{
ID: NewID("cus"),
SubscriptionToken: RandomToken(32),
@@ -1665,11 +1669,15 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
ExpiresAt: expires,
IPLimit: int64(ipLimit),
DeviceLimit: int64(deviceLimit),
TrafficResetDay: resetDay,
Tags: sliceStrings(asIds(body["tags"]), 20),
Notes: CleanText(body["notes"], 1000),
CreatedAt: NowISO(),
UpdatedAt: NowISO(),
}
// Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation.
ArmTrafficReset(next, time.Now())
d.Customers = append(d.Customers, next)
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
return cloneJSONValue(next), nil
@@ -1736,6 +1744,20 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if _, ok := body["tags"]; ok {
item.Tags = sliceStrings(asIds(body["tags"]), 20)
}
if _, ok := body["trafficResetDay"]; ok {
v, err := ParseResetDay(body["trafficResetDay"])
if err != nil {
return nil, err
}
if v != item.TrafficResetDay {
item.TrafficResetDay = v
// A changed schedule is re-armed rather than fired:
// it takes effect from the next reset instant. An
// unchanged value (the UI echoes it on every edit)
// leaves the marker — and any pending reset — alone.
ArmTrafficReset(item, time.Now())
}
}
reason := CustomerBlockReason(d, item)
statusWanted := JSString(body["status"])
if statusWanted == "active" && reason != "" {
+9
View File
@@ -194,6 +194,15 @@ func (a *App) housekeeping() {
}
}
}
// Monthly traffic resets run before the quota evaluation below,
// so a customer whose new period just started is judged on the
// zeroed counters (and a reset can lift a traffic suspension in
// the same pass). Due-ness derives from the persisted period
// marker, so this also catches up resets missed while the panel
// was down — at the first pass after it is back (<=60s).
if err := ApplyMonthlyTrafficResets(d, time.Now()); err != nil {
return nil, err
}
for _, customer := range d.Customers {
expired := false
if customer.ExpiresAt != nil {
+132
View File
@@ -0,0 +1,132 @@
package main
// Monthly traffic reset. A customer with trafficResetDay (1-31) gets the
// cycle counters (usedBytes / usedUplinkBytes / usedDownlinkBytes — the
// same fields the quota checks and the subscription userinfo read) zeroed
// once per monthly period, from the regular 60s housekeeping pass.
//
// The design is deliberately stateless and restart-safe: whether a reset
// is due is derived only from the persisted trafficResetPeriod marker, so
// a reset instant missed while the panel was down fires at the first
// housekeeping pass after it comes back — no in-memory schedule to lose.
// All dates are panel-local, matching the rest of housekeeping.
import "time"
// daysInMonth returns the number of days in the given month (pure
// calendar math, location-independent).
func daysInMonth(year int, month time.Month) int {
// Day 0 of the next month is the last day of this one.
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
// resetInstant returns the scheduled reset instant inside the given
// month: the reset day clamped to the month's length (the 31st fires on
// Feb 28), at 00:00 in loc.
func resetInstant(year int, month time.Month, day int, loc *time.Location) time.Time {
if dim := daysInMonth(year, month); day > dim {
day = dim
}
return time.Date(year, month, day, 0, 0, 0, 0, loc)
}
func prevMonth(year int, month time.Month) (int, time.Month) {
if month == time.January {
return year - 1, time.December
}
return year, month - 1
}
// TrafficResetPeriodKey names the monthly period `now` belongs to for the
// given reset day: the "2006-01" of the most recent scheduled reset
// instant at or before now. Crossing a reset instant flips the key.
func TrafficResetPeriodKey(now time.Time, day int) string {
loc := now.Location()
year, month, _ := now.Date()
cand := resetInstant(year, month, day, loc)
if now.Before(cand) {
// This month's reset instant is still ahead: the current period
// began at last month's reset instant.
year, month = prevMonth(year, month)
cand = resetInstant(year, month, day, loc)
}
return cand.Format("2006-01")
}
// ArmTrafficReset (re)arms a customer's period marker for its current
// reset day WITHOUT zeroing usage: the schedule takes effect from the next
// reset instant. Used when the reset day is set or changed through the
// API, and for customers whose marker is empty (data written before this
// feature existed, or externally — e.g. by the future 3x-ui converter,
// which only has to write trafficResetDay).
func ArmTrafficReset(customer *Customer, now time.Time) {
if customer.TrafficResetDay < 1 || customer.TrafficResetDay > 31 {
customer.TrafficResetPeriod = ""
return
}
customer.TrafficResetPeriod = TrafficResetPeriodKey(now, customer.TrafficResetDay)
}
// ApplyMonthlyTrafficResets performs the due monthly resets for all
// customers in d as of now. Idempotent per period via the persisted
// marker; safe to call on every housekeeping pass.
func ApplyMonthlyTrafficResets(d *Data, now time.Time) error {
for _, customer := range d.Customers {
day := customer.TrafficResetDay
if day < 1 || day > 31 {
continue
}
key := TrafficResetPeriodKey(now, day)
if customer.TrafficResetPeriod == "" {
// First sight: arm the marker only — usage already accrued in
// the current period must survive until the next reset day.
customer.TrafficResetPeriod = key
continue
}
if customer.TrafficResetPeriod == key {
continue
}
// A new period has begun since the last applied reset. Zero the
// cycle counters exactly like the manual reset-usage action (the
// model has no separate lifetime counter: these three fields are
// also what subscriptions report as upload/download).
previousUsed := customer.UsedBytes
customer.UsedBytes = 0
customer.UsedUplinkBytes = 0
customer.UsedDownlinkBytes = 0
customer.TrafficResetPeriod = key
resetAt := ISOFromTime(now)
customer.TrafficResetAt = &resetAt
customer.UpdatedAt = resetAt
Audit(d, "system:monthly_reset", "reset_customer_usage_monthly", customer.ID, map[string]any{
"previousUsedBytes": previousUsed,
"resetDay": day,
"period": key,
})
// Mirror the manual reset: a customer suspended only for the
// traffic limit comes back once nothing blocks it any more.
if customer.Status == "suspended" && strVal(customer.SuspendReason) == "traffic_limit" &&
CustomerBlockReason(d, customer) == "" {
customer.Status = "active"
customer.SuspendReason = nil
customer.SuspendReasonPresent = true
if _, err := ResumeCustomerResources(d, customer, "system:monthly_reset"); err != nil {
return err
}
}
}
return nil
}
// ParseResetDay validates a trafficResetDay request value: 0 disables
// the schedule, 1-31 is the day of month. JSON null/absent means 0.
func ParseResetDay(v any) (int, error) {
n, err := NonNegativeNumber(v, "每月重置日", true)
if err != nil {
return 0, err
}
if n > 31 {
return 0, httpErr(400, "每月重置日须在 0–31 之间(0 表示不重置)")
}
return int(n), nil
}
+420
View File
@@ -0,0 +1,420 @@
package main
// Tests for the monthly traffic reset: period-key calendar math (incl.
// short-month clamping), the housekeeping apply pass (due / arming /
// idempotency / missed-period catch-up / suspension interplay), API
// surface and persistence of the new customer fields.
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
func local(y int, m time.Month, d, hh, mm int) time.Time {
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
}
func TestTrafficResetPeriodKey(t *testing.T) {
cases := []struct {
now time.Time
day int
want string
}{
{local(2026, 10, 20, 10, 0), 15, "2026-10"}, // mid-period
{local(2026, 10, 15, 0, 0), 15, "2026-10"}, // exactly at the instant: new period
{local(2026, 10, 14, 23, 59), 15, "2026-09"},
{local(2026, 10, 20, 0, 0), 20, "2026-10"}, // reset day == today
{local(2026, 10, 1, 0, 0), 1, "2026-10"}, // day 1 at month start
{local(2027, 1, 1, 0, 0), 31, "2026-12"}, // year rollover, before Jan 31
{local(2026, 12, 31, 0, 0), 31, "2026-12"}, // at Dec 31 instant
{local(2027, 2, 27, 12, 0), 31, "2027-01"}, // Feb has no 31st yet
{local(2027, 2, 28, 0, 0), 31, "2027-02"}, // clamped instant: Feb 28
{local(2027, 3, 1, 0, 0), 31, "2027-02"}, // still the Feb period
{local(2027, 2, 28, 12, 0), 30, "2027-02"}, // day 30 clamps to Feb 28 too
{local(2027, 2, 27, 12, 0), 30, "2027-01"},
{local(2028, 2, 29, 0, 0), 29, "2028-02"}, // leap year: Feb 29 exists
{local(2028, 3, 1, 0, 0), 29, "2028-02"},
{local(2027, 3, 1, 0, 0), 29, "2027-02"}, // non-leap: day 29 clamps to Feb 28
{local(2027, 2, 28, 0, 0), 29, "2027-02"},
}
for _, c := range cases {
if got := TrafficResetPeriodKey(c.now, c.day); got != c.want {
t.Errorf("key(%v, %d) = %q, want %q", c.now, c.day, got, c.want)
}
}
}
func resetAudits(d *Data, id string) int {
n := 0
for _, e := range d.Activity {
if e.Action == "reset_customer_usage_monthly" && e.Target == id {
n++
}
}
return n
}
func TestApplyMonthlyResetDueAndIdempotent(t *testing.T) {
c := &Customer{ID: "cus_a", Status: "active", TrafficLimitBytes: 1000,
UsedBytes: 500, UsedUplinkBytes: 200, UsedDownlinkBytes: 300,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{c}}
now := local(2026, 10, 20, 8, 0)
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
t.Fatalf("apply: %v", err)
}
if c.UsedBytes != 0 || c.UsedUplinkBytes != 0 || c.UsedDownlinkBytes != 0 {
t.Fatalf("counters not zeroed: %+v", c)
}
if c.TrafficResetPeriod != "2026-10" {
t.Fatalf("period = %q", c.TrafficResetPeriod)
}
if c.TrafficResetAt == nil || *c.TrafficResetAt != ISOFromTime(now) {
t.Fatalf("resetAt = %v", c.TrafficResetAt)
}
if n := resetAudits(d, "cus_a"); n != 1 {
t.Fatalf("audits = %d", n)
}
// Same period, fresh usage accrues: a second pass must not zero again.
c.UsedBytes, c.UsedUplinkBytes, c.UsedDownlinkBytes = 50, 20, 30
if err := ApplyMonthlyTrafficResets(d, now.Add(24*time.Hour)); err != nil {
t.Fatalf("apply2: %v", err)
}
if c.UsedBytes != 50 {
t.Fatalf("second pass zeroed within the same period: %d", c.UsedBytes)
}
if n := resetAudits(d, "cus_a"); n != 1 {
t.Fatalf("audits after second pass = %d", n)
}
}
func TestApplyMonthlyResetNotYetDue(t *testing.T) {
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
TrafficResetDay: 15, TrafficResetPeriod: "2026-10"}
d := &Data{Customers: []*Customer{c}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 500 || c.TrafficResetAt != nil {
t.Fatalf("not-due customer touched: %+v", c)
}
}
func TestApplyMonthlyResetArmsOnFirstSight(t *testing.T) {
// Marker empty (legacy / converter-written data): arm only, the usage
// accrued in the current period must survive.
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
TrafficResetDay: 15}
d := &Data{Customers: []*Customer{c}}
now := local(2026, 10, 20, 0, 0)
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 500 {
t.Fatalf("first sight zeroed usage: %d", c.UsedBytes)
}
if c.TrafficResetPeriod != "2026-10" || c.TrafficResetAt != nil {
t.Fatalf("arming wrong: period=%q at=%v", c.TrafficResetPeriod, c.TrafficResetAt)
}
// And the armed schedule really fires at the next reset instant.
c.UsedBytes = 700
if err := ApplyMonthlyTrafficResets(d, local(2026, 11, 15, 0, 1)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-11" {
t.Fatalf("armed schedule did not fire: %+v", c)
}
}
func TestApplyMonthlyResetCatchUpMissedPeriods(t *testing.T) {
// Panel down across several reset days (restart catch-up): exactly
// one reset, landing in the current period.
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 900,
TrafficResetDay: 15, TrafficResetPeriod: "2026-06"}
d := &Data{Customers: []*Customer{c}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-10" {
t.Fatalf("catch-up wrong: %+v", c)
}
if n := resetAudits(d, "cus_a"); n != 1 {
t.Fatalf("catch-up audits = %d, want exactly 1", n)
}
}
func TestApplyMonthlyResetShortMonthClamp(t *testing.T) {
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
TrafficResetDay: 31, TrafficResetPeriod: "2027-01"}
d := &Data{Customers: []*Customer{c}}
// Feb 27: the clamped instant (Feb 28) has not arrived — no fire.
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 27, 23, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 100 {
t.Fatalf("fired before the clamped instant")
}
// Feb 28 00:01: fires, period is February's.
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 28, 0, 1)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2027-02" {
t.Fatalf("clamp fire wrong: %+v", c)
}
}
func TestApplyMonthlyResetIndependentDays(t *testing.T) {
a := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
TrafficResetDay: 1, TrafficResetPeriod: "2026-09"}
b := &Customer{ID: "cus_b", Status: "active", UsedBytes: 200,
TrafficResetDay: 25, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{a, b}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if a.UsedBytes != 0 {
t.Errorf("day-1 customer not reset")
}
if b.UsedBytes != 200 || b.TrafficResetAt != nil {
t.Errorf("day-25 customer wrongly reset: %+v", b)
}
}
func TestApplyMonthlyResetUnsetOrInvalidUntouched(t *testing.T) {
off := &Customer{ID: "cus_off", Status: "active", UsedBytes: 100}
bad := &Customer{ID: "cus_bad", Status: "active", UsedBytes: 100,
TrafficResetDay: 45, TrafficResetPeriod: "2020-01"}
neg := &Customer{ID: "cus_neg", Status: "active", UsedBytes: 100,
TrafficResetDay: -3, TrafficResetPeriod: "2020-01"}
d := &Data{Customers: []*Customer{off, bad, neg}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
for _, c := range d.Customers {
if c.UsedBytes != 100 || c.TrafficResetAt != nil {
t.Errorf("customer %s touched: %+v", c.ID, c)
}
}
if off.TrafficResetPeriod != "" {
t.Errorf("unset customer got a marker: %q", off.TrafficResetPeriod)
}
}
func TestApplyMonthlyResetResumesTrafficSuspended(t *testing.T) {
reason := "traffic_limit"
c := &Customer{ID: "cus_a", Status: "suspended", SuspendReason: &reason,
SuspendReasonPresent: true, TrafficLimitBytes: 1000,
UsedBytes: 1500, UsedUplinkBytes: 500, UsedDownlinkBytes: 1000,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{c}}
if got := CustomerBlockReason(d, c); got != "traffic_limit" {
t.Fatalf("precondition block reason = %q", got)
}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.Status != "active" || c.SuspendReason != nil {
t.Fatalf("not resumed after reset: %+v", c)
}
if got := CustomerBlockReason(d, c); got != "" {
t.Fatalf("block reason after reset = %q", got)
}
}
func TestApplyMonthlyResetKeepsOtherSuspensions(t *testing.T) {
manual := "manual"
expired := "expired"
past := "2020-01-01T00:00:00.000Z"
a := &Customer{ID: "cus_m", Status: "suspended", SuspendReason: &manual,
SuspendReasonPresent: true, UsedBytes: 100,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
b := &Customer{ID: "cus_e", Status: "suspended", SuspendReason: &expired,
SuspendReasonPresent: true, UsedBytes: 100, ExpiresAt: &past,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{a, b}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
// Counters reset (a fresh cycle awaits renewal), status untouched.
if a.UsedBytes != 0 || b.UsedBytes != 0 {
t.Fatalf("counters not reset: %d %d", a.UsedBytes, b.UsedBytes)
}
if a.Status != "suspended" || b.Status != "suspended" {
t.Fatalf("suspension lifted wrongly: %s %s", a.Status, b.Status)
}
}
func TestParseResetDay(t *testing.T) {
for in, want := range map[any]int{nil: 0, float64(0): 0, float64(1): 1, float64(31): 31, json.Number("15"): 15} {
got, err := ParseResetDay(in)
if err != nil || got != want {
t.Errorf("ParseResetDay(%v) = %d, %v; want %d", in, got, err, want)
}
}
for _, in := range []any{float64(32), float64(2.5), float64(-1)} {
if _, err := ParseResetDay(in); err == nil {
t.Errorf("ParseResetDay(%v) accepted", in)
}
}
}
func TestResetFieldsPersistAndOmitWhenUnset(t *testing.T) {
s, file := newTestStore(t)
at := "2026-10-15T00:00:00.000Z"
_, err := s.Transaction(func(d *Data) (any, error) {
d.Customers = append(d.Customers,
&Customer{ID: "cus_set", Name: "set", Status: "active",
TrafficResetDay: 15, TrafficResetPeriod: "2026-10", TrafficResetAt: &at},
&Customer{ID: "cus_unset", Name: "unset", Status: "active"})
return nil, nil
})
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"trafficResetDay":15`) ||
!strings.Contains(string(raw), `"trafficResetPeriod":"2026-10"`) ||
!strings.Contains(string(raw), `"trafficResetAt":"2026-10-15T00:00:00.000Z"`) {
t.Fatalf("reset fields not persisted: %s", raw)
}
// Reload through a fresh store: fields survive the round trip.
s2 := NewStore(file)
if err := s2.Init(); err != nil {
t.Fatal(err)
}
var set, unset *Customer
for _, c := range s2.Data.Customers {
if c.ID == "cus_set" {
set = c
} else {
unset = c
}
}
if set == nil || set.TrafficResetDay != 15 || set.TrafficResetPeriod != "2026-10" ||
set.TrafficResetAt == nil || *set.TrafficResetAt != at {
t.Fatalf("round trip lost fields: %+v", set)
}
// Unset customer: none of the new keys may appear in its JSON.
out, err := json.Marshal(unset)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "trafficReset") {
t.Fatalf("unset customer gained reset keys: %s", out)
}
}
func TestHousekeepingAppliesMonthlyReset(t *testing.T) {
s, _ := newTestStore(t)
now := time.Now()
day := now.Day()
_, err := s.Transaction(func(d *Data) (any, error) {
d.Customers = append(d.Customers, &Customer{ID: "cus_a", Name: "a", Status: "active",
UsedBytes: 123, TrafficResetDay: day, TrafficResetPeriod: "2000-01"})
return nil, nil
})
if err != nil {
t.Fatal(err)
}
app := NewApp(s, NewSessionManager(12), NewOrchestrator(s))
app.housekeeping()
c := s.Snapshot().Customers[0]
if c.UsedBytes != 0 {
t.Fatalf("housekeeping did not reset: %d", c.UsedBytes)
}
if want := TrafficResetPeriodKey(now, day); c.TrafficResetPeriod != want {
t.Fatalf("period = %q, want %q", c.TrafficResetPeriod, want)
}
// Second pass with fresh usage: no double reset through the real loop.
_, err = s.Transaction(func(d *Data) (any, error) {
d.Customers[0].UsedBytes = 7
return nil, nil
})
if err != nil {
t.Fatal(err)
}
app.housekeeping()
if got := s.Snapshot().Customers[0].UsedBytes; got != 7 {
t.Fatalf("second housekeeping pass zeroed again: %d", got)
}
}
func loginHeaders(t *testing.T, app *App) map[string]string {
t.Helper()
rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
if rec.Code != 200 {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
body := decodeBody(t, rec)
csrf, _ := body["csrf"].(string)
cookie := ""
for _, h := range rec.Header().Values("Set-Cookie") {
if strings.HasPrefix(h, "ng_session=") {
cookie = strings.Split(h, ";")[0]
}
}
if csrf == "" || cookie == "" {
t.Fatalf("login artifacts missing: csrf=%q cookie=%q", csrf, cookie)
}
return map[string]string{"Cookie": cookie, "X-CSRF-Token": csrf}
}
func TestCustomerAPIResetDayLifecycle(t *testing.T) {
app := newSmokeApp(t)
headers := loginHeaders(t, app)
// Create with a reset day: armed marker for the current period, no
// reset timestamp yet.
rec := doRequest(app, "POST", "/api/customers",
`{"name":"monthly","trafficResetDay":15}`, headers)
if rec.Code != 201 {
t.Fatalf("create %d %s", rec.Code, rec.Body.String())
}
customer, _ := decodeBody(t, rec)["customer"].(map[string]any)
if customer == nil || customer["trafficResetDay"] != float64(15) {
t.Fatalf("create shape %v", customer)
}
if want := TrafficResetPeriodKey(time.Now(), 15); customer["trafficResetPeriod"] != want {
t.Fatalf("armed period = %v, want %q", customer["trafficResetPeriod"], want)
}
if _, ok := customer["trafficResetAt"]; ok {
t.Fatalf("fresh customer has resetAt: %v", customer)
}
id, _ := customer["id"].(string)
// Out-of-range day is rejected.
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":32}`, headers)
if rec.Code != 400 {
t.Fatalf("patch 32 status %d %s", rec.Code, rec.Body.String())
}
// Changing the day re-arms for the new schedule.
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":20}`, headers)
if rec.Code != 200 {
t.Fatalf("patch 20 status %d %s", rec.Code, rec.Body.String())
}
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
if customer["trafficResetDay"] != float64(20) ||
customer["trafficResetPeriod"] != TrafficResetPeriodKey(time.Now(), 20) {
t.Fatalf("re-armed shape %v", customer)
}
// Clearing the day removes the schedule from the record entirely.
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":0}`, headers)
if rec.Code != 200 {
t.Fatalf("patch 0 status %d %s", rec.Code, rec.Body.String())
}
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
if _, ok := customer["trafficResetDay"]; ok {
t.Fatalf("cleared customer still carries resetDay: %v", customer)
}
if _, ok := customer["trafficResetPeriod"]; ok {
t.Fatalf("cleared customer still carries resetPeriod: %v", customer)
}
}
+3
View File
@@ -532,6 +532,9 @@ type Customer struct {
UsedBytes int64 `json:"usedBytes"`
UsedUplinkBytes int64 `json:"usedUplinkBytes"`
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
TrafficResetDay int `json:"trafficResetDay,omitempty"`
TrafficResetPeriod string `json:"trafficResetPeriod,omitempty"`
TrafficResetAt *string `json:"trafficResetAt,omitempty"`
ExpiresAt *string `json:"expiresAt"`
IPLimit int64 `json:"ipLimit"`
DeviceLimit int64 `json:"deviceLimit"`