client_stat_reuse_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. package service
  2. import (
  3. "testing"
  4. "github.com/mhsanaei/3x-ui/v3/internal/database"
  5. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  6. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  7. )
  8. // TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse covers #5958:
  9. // deleting a client's only inbound leaves its clients/client_traffics rows in
  10. // place (matching ClientService.Detach, which does the same on purpose so a
  11. // later Attach can resume a client with its accumulated traffic intact). If
  12. // that same email is instead reused for a freshly (re)created client via
  13. // ClientService.Create, the new enable/expiry/reset/total must win over
  14. // whatever the orphaned row still holds instead of being silently ignored by
  15. // AddClientStat's OnConflict.
  16. func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
  17. setupBulkDB(t)
  18. svc := &ClientService{}
  19. inboundSvc := &InboundService{}
  20. const email = "[email protected]"
  21. const subID = "sub-reused"
  22. ibA := mkInbound(t, 22001, model.VLESS, `{"clients":[]}`)
  23. // Create starts every client enabled (it forces Enable=true), so the
  24. // depleted/disabled shape a client has by the time it's naturally deleted
  25. // is reached the same way production reaches it: a follow-up Update, not
  26. // the initial Create.
  27. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  28. Client: model.Client{
  29. Email: email, SubID: subID, Enable: true,
  30. TotalGB: 0, ExpiryTime: 1000, Reset: 0,
  31. },
  32. InboundIds: []int{ibA.Id},
  33. }); err != nil {
  34. t.Fatalf("initial Create: %v", err)
  35. }
  36. rec0 := lookupClientRecord(t, email)
  37. if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
  38. Email: email, SubID: subID, Enable: false,
  39. TotalGB: 0, ExpiryTime: 1000, Reset: 0,
  40. }); err != nil {
  41. t.Fatalf("Update to disabled: %v", err)
  42. }
  43. db := database.GetDB()
  44. var before xray.ClientTraffic
  45. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&before).Error; err != nil {
  46. t.Fatalf("lookup client_traffics before delete: %v", err)
  47. }
  48. if before.Enable || before.Reset != 0 || before.Total != 0 {
  49. t.Fatalf("unexpected initial client_traffics row: %+v", before)
  50. }
  51. // Delete the client's only inbound. This must NOT delete the client or its
  52. // traffic row (that's the documented, intentional Detach-parity behavior) —
  53. // it only orphans them.
  54. if _, err := inboundSvc.DelInbound(ibA.Id); err != nil {
  55. t.Fatalf("DelInbound: %v", err)
  56. }
  57. rec := lookupClientRecord(t, email)
  58. ids, err := svc.GetInboundIdsForRecord(rec.Id)
  59. if err != nil {
  60. t.Fatalf("GetInboundIdsForRecord: %v", err)
  61. }
  62. if len(ids) != 0 {
  63. t.Fatalf("client should be fully detached after its only inbound was deleted, still attached to: %v", ids)
  64. }
  65. var stillThere xray.ClientTraffic
  66. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&stillThere).Error; err != nil {
  67. t.Fatalf("client_traffics row should survive inbound deletion (Detach parity), lookup failed: %v", err)
  68. }
  69. // Reuse the same email + subId (the only way ClientService.Create allows
  70. // re-adding under an already-used email) on a freshly created inbound, with
  71. // deliberately different, "fresh" settings.
  72. ibB := mkInbound(t, 22002, model.VLESS, `{"clients":[]}`)
  73. const wantExpiry = int64(9999999999000)
  74. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  75. Client: model.Client{
  76. Email: email, SubID: subID, Enable: true,
  77. TotalGB: 10 << 30, ExpiryTime: wantExpiry, Reset: 5,
  78. },
  79. InboundIds: []int{ibB.Id},
  80. }); err != nil {
  81. t.Fatalf("reuse Create: %v", err)
  82. }
  83. var after xray.ClientTraffic
  84. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&after).Error; err != nil {
  85. t.Fatalf("lookup client_traffics after reuse: %v", err)
  86. }
  87. if !after.Enable {
  88. t.Errorf("client_traffics.enable still stale (false) after reuse, want true")
  89. }
  90. if after.Reset != 5 {
  91. t.Errorf("client_traffics.reset = %d, want 5 (stale value from before delete was 0)", after.Reset)
  92. }
  93. if after.Total != 10<<30 {
  94. t.Errorf("client_traffics.total = %d, want %d", after.Total, int64(10<<30))
  95. }
  96. if after.ExpiryTime != wantExpiry {
  97. t.Errorf("client_traffics.expiry_time = %d, want %d", after.ExpiryTime, wantExpiry)
  98. }
  99. if after.InboundId != ibB.Id {
  100. t.Errorf("client_traffics.inbound_id = %d, want refreshed to new inbound %d (was %d)", after.InboundId, ibB.Id, ibA.Id)
  101. }
  102. // up/down are deliberately NOT refreshed by AddClientStat — confirm that
  103. // stays true (would matter if the original client had real usage).
  104. if after.Up != before.Up || after.Down != before.Down {
  105. t.Errorf("up/down should be left untouched by the conflict refresh: before up=%d down=%d, after up=%d down=%d",
  106. before.Up, before.Down, after.Up, after.Down)
  107. }
  108. }
  109. // TestAddClientStat_MultiInboundReattachStaysIdempotent guards the legitimate
  110. // case AddClientStat's OnConflict is also responsible for: a client attached
  111. // to two inbounds at once shares one client_traffics row, and re-asserting
  112. // its own current settings for the second inbound must be a no-op in effect,
  113. // not a data loss. In particular it must not zero out real accumulated
  114. // traffic just because the client gained a second attachment.
  115. func TestAddClientStat_MultiInboundReattachStaysIdempotent(t *testing.T) {
  116. setupBulkDB(t)
  117. svc := &ClientService{}
  118. inboundSvc := &InboundService{}
  119. const email = "[email protected]"
  120. const subID = "sub-multi"
  121. ibA := mkInbound(t, 22003, model.VLESS, `{"clients":[]}`)
  122. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  123. Client: model.Client{
  124. Email: email, SubID: subID, Enable: true,
  125. TotalGB: 5 << 30, ExpiryTime: 42, Reset: 3,
  126. },
  127. InboundIds: []int{ibA.Id},
  128. }); err != nil {
  129. t.Fatalf("first Create: %v", err)
  130. }
  131. db := database.GetDB()
  132. // Simulate real accumulated usage before the second attachment.
  133. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
  134. Updates(map[string]any{"up": int64(123), "down": int64(456)}).Error; err != nil {
  135. t.Fatalf("seed usage: %v", err)
  136. }
  137. ibB := mkInbound(t, 22004, model.VLESS, `{"clients":[]}`)
  138. // Re-adding the same identity to a second inbound: same email/subId/settings,
  139. // exactly what the panel does when attaching an existing client elsewhere.
  140. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  141. Client: model.Client{
  142. Email: email, SubID: subID, Enable: true,
  143. TotalGB: 5 << 30, ExpiryTime: 42, Reset: 3,
  144. },
  145. InboundIds: []int{ibB.Id},
  146. }); err != nil {
  147. t.Fatalf("second Create (attach to ibB): %v", err)
  148. }
  149. var row xray.ClientTraffic
  150. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
  151. t.Fatalf("lookup after second attach: %v", err)
  152. }
  153. if row.Up != 123 || row.Down != 456 {
  154. t.Errorf("accumulated traffic was reset by re-attach: up=%d down=%d, want 123/456", row.Up, row.Down)
  155. }
  156. if !row.Enable || row.Reset != 3 || row.Total != 5<<30 || row.ExpiryTime != 42 {
  157. t.Errorf("config columns changed unexpectedly on idempotent re-assert: %+v", row)
  158. }
  159. }