inbound_autorenew_calendar_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. package service
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "time"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  9. )
  10. // pinPanelZone fixes the panel time zone so the assertions below can talk about
  11. // calendar days without the test machine's own zone shifting them.
  12. func pinPanelZone(t *testing.T, name string) *time.Location {
  13. t.Helper()
  14. loc, err := time.LoadLocation(name)
  15. if err != nil {
  16. t.Skipf("zone database unavailable: %v", err)
  17. }
  18. if err := database.GetDB().Create(&model.Setting{Key: "timeLocation", Value: name}).Error; err != nil {
  19. t.Fatalf("pin panel zone: %v", err)
  20. }
  21. return loc
  22. }
  23. // Calendar mode renews on the same day each month. The interval mode drifts —
  24. // 30 days from 31 January is 2 March — which is the whole reason for the mode.
  25. func TestAutoRenewClients_CalendarModeLandsOnTheBillingDay(t *testing.T) {
  26. setupBulkDB(t)
  27. svc := &InboundService{}
  28. db := database.GetDB()
  29. zone := pinPanelZone(t, "UTC")
  30. // Expired two calendar months ago, billed on the 15th.
  31. past := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC)
  32. clients := []model.Client{
  33. {Email: "cal@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, ResetDay: 15, ExpiryTime: past.UnixMilli()},
  34. }
  35. ib := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients))
  36. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  37. t.Fatalf("SyncInbound: %v", err)
  38. }
  39. if err := db.Create(&xray.ClientTraffic{
  40. InboundId: ib.Id, Email: "cal@x", Enable: false, Up: 5, Down: 6,
  41. ResetDay: 15, ExpiryTime: past.UnixMilli(),
  42. }).Error; err != nil {
  43. t.Fatalf("seed client_traffics: %v", err)
  44. }
  45. if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
  46. t.Fatalf("autoRenewClients: %v", err)
  47. } else if count != 1 {
  48. t.Fatalf("renewed count = %d, want 1", count)
  49. }
  50. var row xray.ClientTraffic
  51. if err := db.Where("email = ?", "cal@x").First(&row).Error; err != nil {
  52. t.Fatal(err)
  53. }
  54. got := time.UnixMilli(row.ExpiryTime).In(zone)
  55. if got.Day() != 15 {
  56. t.Fatalf("renewed to %s, want the 15th: calendar mode must not drift", got.Format(time.RFC3339))
  57. }
  58. if !got.After(time.Now()) {
  59. t.Fatalf("renewed to %s, which is not in the future", got.Format(time.RFC3339))
  60. }
  61. if h, m, s := got.Clock(); h != 0 || m != 0 || s != 0 {
  62. t.Fatalf("renewed to %02d:%02d:%02d, want midnight", h, m, s)
  63. }
  64. if row.Up != 0 || row.Down != 0 {
  65. t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down)
  66. }
  67. if !row.Enable {
  68. t.Fatal("a renewed client must be re-enabled")
  69. }
  70. }
  71. // A client billed on the 31st keeps that day, borrowing the last day only in
  72. // months that are too short for it.
  73. func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) {
  74. setupBulkDB(t)
  75. svc := &InboundService{}
  76. db := database.GetDB()
  77. zone := pinPanelZone(t, "UTC")
  78. past := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
  79. clients := []model.Client{
  80. {Email: "eom@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli()},
  81. }
  82. ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients))
  83. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  84. t.Fatalf("SyncInbound: %v", err)
  85. }
  86. if err := db.Create(&xray.ClientTraffic{
  87. InboundId: ib.Id, Email: "eom@x", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli(),
  88. }).Error; err != nil {
  89. t.Fatalf("seed client_traffics: %v", err)
  90. }
  91. if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
  92. t.Fatalf("autoRenewClients: %v", err)
  93. }
  94. var row xray.ClientTraffic
  95. if err := db.Where("email = ?", "eom@x").First(&row).Error; err != nil {
  96. t.Fatal(err)
  97. }
  98. got := time.UnixMilli(row.ExpiryTime).In(zone)
  99. want := firstBillingMidnightAfter(t, time.Now().In(zone), 31, zone)
  100. if !got.Equal(want) {
  101. t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
  102. }
  103. }
  104. // Deliberately not built on nextCalendarRenewal: it walks a day at a time and
  105. // derives month length from time.Date's own zero-day trick, so it can disagree.
  106. func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time {
  107. t.Helper()
  108. cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
  109. for i := 0; i < 400; i++ {
  110. cur = cur.AddDate(0, 0, 1)
  111. want := day
  112. if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last {
  113. want = last
  114. }
  115. if cur.Day() == want {
  116. return cur
  117. }
  118. }
  119. t.Fatalf("no billing midnight for day %d within a year of %s", day, from)
  120. return time.Time{}
  121. }
  122. // Interval clients must be untouched by the new field.
  123. func TestAutoRenewClients_IntervalModeUnchanged(t *testing.T) {
  124. setupBulkDB(t)
  125. svc := &InboundService{}
  126. db := database.GetDB()
  127. past := time.Now().Add(-48 * time.Hour).UnixMilli()
  128. clients := []model.Client{
  129. {Email: "days@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ExpiryTime: past},
  130. }
  131. ib := mkInbound(t, 30203, model.VLESS, clientsSettings(t, clients))
  132. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  133. t.Fatalf("SyncInbound: %v", err)
  134. }
  135. if err := db.Create(&xray.ClientTraffic{
  136. InboundId: ib.Id, Email: "days@x", Enable: false, Reset: 30, ExpiryTime: past,
  137. }).Error; err != nil {
  138. t.Fatalf("seed client_traffics: %v", err)
  139. }
  140. if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
  141. t.Fatalf("autoRenewClients: %v", err)
  142. } else if count != 1 {
  143. t.Fatalf("renewed count = %d, want 1", count)
  144. }
  145. var row xray.ClientTraffic
  146. if err := db.Where("email = ?", "days@x").First(&row).Error; err != nil {
  147. t.Fatal(err)
  148. }
  149. if want := past + 30*86400000; row.ExpiryTime != want {
  150. t.Fatalf("interval renewal moved to %d, want the old fixed step %d", row.ExpiryTime, want)
  151. }
  152. }
  153. // The selection filter is what keeps a row with neither mode configured out of
  154. // the renewal loop. That matters more than it looks: the interval step is
  155. // reset*24h, so a zero interval reaching that loop would spin forever on the
  156. // single traffic writer. The guard in the loop is a second line of defence and
  157. // is deliberately unreachable while this filter holds.
  158. func TestAutoRenewClients_RowWithNoModeIsNotSelected(t *testing.T) {
  159. setupBulkDB(t)
  160. svc := &InboundService{}
  161. db := database.GetDB()
  162. past := time.Now().Add(-48 * time.Hour).UnixMilli()
  163. clients := []model.Client{
  164. {Email: "none@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, ExpiryTime: past},
  165. }
  166. ib := mkInbound(t, 30204, model.VLESS, clientsSettings(t, clients))
  167. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  168. t.Fatalf("SyncInbound: %v", err)
  169. }
  170. // Seeded straight into the table with both modes off, the shape the
  171. // selection filter is supposed to exclude.
  172. if err := db.Create(&xray.ClientTraffic{
  173. InboundId: ib.Id, Email: "none@x", Enable: false, Reset: 0, ResetDay: 0, ExpiryTime: past,
  174. }).Error; err != nil {
  175. t.Fatalf("seed client_traffics: %v", err)
  176. }
  177. // Asserted against the query rather than by running the loop: the failure
  178. // this guards is a hang, and a stuck goroutine outlives the test's DB.
  179. var selected int64
  180. if err := db.Model(&xray.ClientTraffic{}).
  181. Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", time.Now().UnixMilli()).
  182. Where("email = ?", "none@x").
  183. Count(&selected).Error; err != nil {
  184. t.Fatal(err)
  185. }
  186. if selected != 0 {
  187. t.Fatal("a row with no renewal mode was selected for renewal: it would reach the interval loop and spin forever")
  188. }
  189. if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
  190. t.Fatalf("autoRenewClients: %v", err)
  191. } else if count != 0 {
  192. t.Fatalf("renewed count = %d, want 0", count)
  193. }
  194. var row xray.ClientTraffic
  195. if err := db.Where("email = ?", "none@x").First(&row).Error; err != nil {
  196. t.Fatal(err)
  197. }
  198. if row.ExpiryTime != past {
  199. t.Fatalf("a client with no renewal mode was renewed to %d", row.ExpiryTime)
  200. }
  201. }
  202. // The billing day has to survive the clients table, not just the settings JSON:
  203. // an ordinary edit rebuilds the client from the record and writes it back (#6106).
  204. func TestClientEditKeepsTheBillingDay(t *testing.T) {
  205. setupBulkDB(t)
  206. svc := &InboundService{}
  207. db := database.GetDB()
  208. clients := []model.Client{
  209. {Email: "keep@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, ResetDay: 20, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
  210. }
  211. ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
  212. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  213. t.Fatalf("SyncInbound: %v", err)
  214. }
  215. mkTraffic(t, ib.Id, "keep@x", 10, 20, 0, 0, true)
  216. rec, err := svc.clientService.GetRecordByEmail(nil, "keep@x")
  217. if err != nil {
  218. t.Fatalf("GetRecordByEmail: %v", err)
  219. }
  220. if rec.ResetDay != 20 {
  221. t.Fatalf("clients.reset_day = %d, want the 20 the client was created with", rec.ResetDay)
  222. }
  223. // What the edit dialog does: hydrate the record, change something else, save.
  224. edited := rec.ToClient()
  225. edited.Comment = "renamed"
  226. if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
  227. t.Fatalf("Update: %v", err)
  228. }
  229. // The inbound JSON is what xray and the edit dialog read back, and it is
  230. // rebuilt from the record, so it is where a dropped converter field shows.
  231. var stored model.Inbound
  232. if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
  233. t.Fatal(err)
  234. }
  235. var settings struct {
  236. Clients []model.Client `json:"clients"`
  237. }
  238. if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
  239. t.Fatalf("parse inbound settings: %v", err)
  240. }
  241. if len(settings.Clients) != 1 {
  242. t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
  243. }
  244. if settings.Clients[0].ResetDay != 20 {
  245. t.Fatalf("inbound settings resetDay = %d after an unrelated edit, want 20: calendar mode was silently turned off", settings.Clients[0].ResetDay)
  246. }
  247. rec, err = svc.clientService.GetRecordByEmail(nil, "keep@x")
  248. if err != nil {
  249. t.Fatalf("GetRecordByEmail after edit: %v", err)
  250. }
  251. if rec.ResetDay != 20 {
  252. t.Fatalf("clients.reset_day = %d after an unrelated edit, want 20", rec.ResetDay)
  253. }
  254. var row xray.ClientTraffic
  255. if err := db.Where("email = ?", "keep@x").First(&row).Error; err != nil {
  256. t.Fatal(err)
  257. }
  258. if row.ResetDay != 20 {
  259. t.Fatalf("client_traffics.reset_day = %d after an unrelated edit, want 20", row.ResetDay)
  260. }
  261. }
  262. // The billing day is useless if it can only be chosen once. The test above
  263. // passes even without the record write, because nothing overwrites the value
  264. // it checks; this one fails without it.
  265. func TestClientEditChangesTheBillingDay(t *testing.T) {
  266. setupBulkDB(t)
  267. svc := &InboundService{}
  268. clients := []model.Client{
  269. {
  270. Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, ResetDay: 20,
  271. ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
  272. },
  273. }
  274. ib := mkInbound(t, 30206, model.VLESS, clientsSettings(t, clients))
  275. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  276. t.Fatalf("SyncInbound: %v", err)
  277. }
  278. mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true)
  279. rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
  280. if err != nil {
  281. t.Fatalf("GetRecordByEmail: %v", err)
  282. }
  283. edited := rec.ToClient()
  284. edited.ResetDay = 5
  285. if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
  286. t.Fatalf("Update: %v", err)
  287. }
  288. rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
  289. if err != nil {
  290. t.Fatalf("GetRecordByEmail after edit: %v", err)
  291. }
  292. if rec.ResetDay != 5 {
  293. t.Fatalf("clients.reset_day = %d after the operator moved the billing day to the 5th", rec.ResetDay)
  294. }
  295. // Turning calendar mode off has to work too.
  296. edited = rec.ToClient()
  297. edited.ResetDay = 0
  298. edited.Reset = 30
  299. if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
  300. t.Fatalf("Update back to interval mode: %v", err)
  301. }
  302. rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
  303. if err != nil {
  304. t.Fatalf("GetRecordByEmail after switching mode: %v", err)
  305. }
  306. if rec.ResetDay != 0 {
  307. t.Fatalf("clients.reset_day = %d after the operator switched back to interval mode", rec.ResetDay)
  308. }
  309. }
  310. // The two renewal features meet here: a calendar client is capped like an
  311. // interval one, spending one allowance per month rather than per tick.
  312. func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) {
  313. setupBulkDB(t)
  314. svc := &InboundService{}
  315. db := database.GetDB()
  316. zone := pinPanelZone(t, "UTC")
  317. // Three calendar months behind with one allowance left: a single month step
  318. // cannot reach the present, so the client stays expired on its billing day.
  319. past := time.Now().In(zone).AddDate(0, -3, 0)
  320. past = time.Date(past.Year(), past.Month(), 10, 0, 0, 0, 0, zone)
  321. clients := []model.Client{
  322. {Email: "calcap@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 10, ResetMax: 3, ExpiryTime: past.UnixMilli()},
  323. }
  324. ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
  325. if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
  326. t.Fatalf("SyncInbound: %v", err)
  327. }
  328. if err := db.Create(&xray.ClientTraffic{
  329. InboundId: ib.Id, Email: "calcap@x", Enable: false, ResetDay: 10, ResetMax: 3, ResetCount: 2,
  330. Up: 111, Down: 222, ExpiryTime: past.UnixMilli(),
  331. }).Error; err != nil {
  332. t.Fatalf("seed client_traffics: %v", err)
  333. }
  334. if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
  335. t.Fatalf("autoRenewClients: %v", err)
  336. }
  337. var row xray.ClientTraffic
  338. if err := db.Where("email = ?", "calcap@x").First(&row).Error; err != nil {
  339. t.Fatal(err)
  340. }
  341. got := time.UnixMilli(row.ExpiryTime).In(zone)
  342. if want := past.AddDate(0, 1, 0); !got.Equal(want) {
  343. t.Fatalf("renewed to %s, want exactly one month on to %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
  344. }
  345. if row.ResetCount != 3 {
  346. t.Fatalf("resetCount = %d, want 3: one allowance per month stepped", row.ResetCount)
  347. }
  348. if row.Enable {
  349. t.Fatal("a client still expired after a truncated catch-up was enabled")
  350. }
  351. if row.Up != 111 || row.Down != 222 {
  352. t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down)
  353. }
  354. }