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