Files
NexusGate/panel-go/reset_test.go
aaaamoooo c6f6b9dbea 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
2026-10-21 03:16:30 +08:00

421 lines
15 KiB
Go

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