inbound_import_shared_clients_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  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. // makeImportInbound builds an inbound shaped like the import payload: a clients
  9. // JSON blob plus carried-over ClientStats (the exported traffic counters). The
  10. // stats mirror what controller.importInbound feeds AddInbound after zeroing ids.
  11. func makeImportInbound(tag string, port int, settings string, stats []xray.ClientTraffic) *model.Inbound {
  12. for i := range stats {
  13. stats[i].Id = 0
  14. stats[i].Enable = true
  15. }
  16. return &model.Inbound{
  17. UserId: 1,
  18. Tag: tag,
  19. Enable: true,
  20. Listen: "0.0.0.0",
  21. Port: port,
  22. Protocol: model.VLESS,
  23. StreamSettings: `{"network":"tcp"}`,
  24. Settings: settings,
  25. ClientStats: stats,
  26. }
  27. }
  28. // TestAddInbound_ImportTwoInboundsSharingClients reproduces the panel report:
  29. // importing inbound #1 then inbound #2 when both carry the same clients (same
  30. // email + subId) used to fail with "UNIQUE constraint failed: client_traffics.email".
  31. // The shared email already owns a row from the first import, and the second
  32. // inbound's ClientStats association tried to plain-INSERT it again.
  33. func TestAddInbound_ImportTwoInboundsSharingClients(t *testing.T) {
  34. setupConflictDB(t)
  35. svc := &InboundService{}
  36. // Inbound #1: clients alice (shared) and bob (unique to #1).
  37. settings1 := `{"clients":[` +
  38. `{"id":"11111111-1111-1111-1111-111111111111","email":"alice","subId":"s-alice","enable":true},` +
  39. `{"id":"22222222-2222-2222-2222-222222222222","email":"bob","subId":"s-bob","enable":true}` +
  40. `],"decryption":"none","encryption":"none"}`
  41. in1 := makeImportInbound("in-9101-tcp", 9101, settings1, []xray.ClientTraffic{
  42. {Email: "alice", Up: 100, Down: 200, Total: 1000},
  43. {Email: "bob", Up: 1, Down: 2, Total: 1000},
  44. })
  45. if _, _, err := svc.AddInbound(in1); err != nil {
  46. t.Fatalf("import inbound #1: %v", err)
  47. }
  48. // Inbound #2: clients alice (same email+subId as #1) and carol (unique to #2).
  49. settings2 := `{"clients":[` +
  50. `{"id":"11111111-1111-1111-1111-111111111111","email":"alice","subId":"s-alice","enable":true},` +
  51. `{"id":"33333333-3333-3333-3333-333333333333","email":"carol","subId":"s-carol","enable":true}` +
  52. `],"decryption":"none","encryption":"none"}`
  53. in2 := makeImportInbound("in-9102-tcp", 9102, settings2, []xray.ClientTraffic{
  54. {Email: "alice", Up: 999, Down: 999, Total: 9999}, // would clobber the shared row if inserted
  55. {Email: "carol", Up: 3, Down: 4, Total: 1000},
  56. })
  57. if _, _, err := svc.AddInbound(in2); err != nil {
  58. t.Fatalf("import inbound #2 (the reported failure): %v", err)
  59. }
  60. // One traffic row per distinct email — no duplicate "alice".
  61. for _, tc := range []struct {
  62. email string
  63. want int64
  64. }{
  65. {"alice", 100}, // preserved from import #1, not clobbered by #2's 999
  66. {"bob", 1},
  67. {"carol", 3},
  68. } {
  69. var rows []xray.ClientTraffic
  70. if err := database.GetDB().Where("email = ?", tc.email).Find(&rows).Error; err != nil {
  71. t.Fatalf("query %s: %v", tc.email, err)
  72. }
  73. if len(rows) != 1 {
  74. t.Fatalf("email %q: got %d traffic rows, want exactly 1", tc.email, len(rows))
  75. }
  76. if rows[0].Up != tc.want {
  77. t.Fatalf("email %q: Up = %d, want %d (shared row should keep the first import's counters)", tc.email, rows[0].Up, tc.want)
  78. }
  79. }
  80. }
  81. // TestAddInbound_ImportStatsMissingClientStillGetsTrafficRow covers an import
  82. // payload whose clientStats doesn't cover every client in settings (older
  83. // exports / hand-edited JSON): the uncovered client must still end up with a
  84. // traffic row, or it would escape quota and expiry accounting.
  85. func TestAddInbound_ImportStatsMissingClientStillGetsTrafficRow(t *testing.T) {
  86. setupConflictDB(t)
  87. svc := &InboundService{}
  88. settings := `{"clients":[` +
  89. `{"id":"44444444-4444-4444-4444-444444444444","email":"dave","subId":"s-dave","enable":true,"totalGB":1000},` +
  90. `{"id":"55555555-5555-5555-5555-555555555555","email":"erin","subId":"s-erin","enable":true,"totalGB":2000}` +
  91. `],"decryption":"none","encryption":"none"}`
  92. // Stats cover dave only; erin is missing.
  93. in := makeImportInbound("in-9103-tcp", 9103, settings, []xray.ClientTraffic{
  94. {Email: "dave", Up: 7, Down: 8, Total: 1000},
  95. })
  96. if _, _, err := svc.AddInbound(in); err != nil {
  97. t.Fatalf("import inbound: %v", err)
  98. }
  99. var dave xray.ClientTraffic
  100. if err := database.GetDB().Where("email = ?", "dave").First(&dave).Error; err != nil {
  101. t.Fatalf("dave row: %v", err)
  102. }
  103. if dave.Up != 7 {
  104. t.Fatalf("dave Up = %d, want 7 (imported counters preserved)", dave.Up)
  105. }
  106. var erin xray.ClientTraffic
  107. if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
  108. t.Fatalf("erin must still get a traffic row despite missing from clientStats: %v", err)
  109. }
  110. if erin.Up != 0 || erin.Down != 0 {
  111. t.Fatalf("erin counters = %d/%d, want zeroed", erin.Up, erin.Down)
  112. }
  113. if erin.Total != 2000 {
  114. t.Fatalf("erin Total = %d, want 2000 (quota taken from client settings)", erin.Total)
  115. }
  116. }