remark_vars_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. package sub
  2. import (
  3. "strings"
  4. "testing"
  5. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  6. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  7. )
  8. const gb = int64(1024 * 1024 * 1024)
  9. // expandCtx builds a remarkContext from explicit pieces for token tests.
  10. func expandCtx(client model.Client, stats xray.ClientTraffic, inbound *model.Inbound) remarkContext {
  11. return remarkContext{client: client, stats: stats, inbound: inbound}
  12. }
  13. func TestExpandRemarkVars(t *testing.T) {
  14. inbound := &model.Inbound{Remark: "Germany"}
  15. client := model.Client{
  16. Email: "[email protected]",
  17. ID: "3f2a9c1b-aaaa-bbbb-cccc-1234567890ab",
  18. TgID: 123456789,
  19. SubID: "subABC",
  20. Comment: "vip",
  21. Reset: 30,
  22. CreatedAt: 1_700_000_000_000,
  23. }
  24. // 50GB total, 8GB used (5 up + 3 down), enabled, no expiry.
  25. stats := xray.ClientTraffic{
  26. Enable: true,
  27. Total: 50 * gb,
  28. Up: 5 * gb,
  29. Down: 3 * gb,
  30. }
  31. ctx := expandCtx(client, stats, inbound)
  32. cases := []struct{ tmpl, want string }{
  33. {"{{EMAIL}}", "[email protected]"},
  34. {"{{USERNAME}}", "[email protected]"},
  35. {"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
  36. {"{{HOST}}", ""}, // no host remark in ctx → empty
  37. {"{{ID}}", client.ID},
  38. {"{{SHORT_ID}}", "3f2a9c1b"},
  39. {"{{TELEGRAM_ID}}", "123456789"},
  40. {"{{SUB_ID}}", "subABC"},
  41. {"{{COMMENT}}", "vip"},
  42. {"{{RESET_DAYS}}", "30"},
  43. {"{{CREATED_UNIX}}", "1700000000"},
  44. {"{{TRAFFIC_USED}}", "8.00GB"},
  45. {"{{TRAFFIC_LEFT}}", "42.00GB"},
  46. {"{{TRAFFIC_TOTAL}}", "50.00GB"},
  47. {"{{TRAFFIC_USED_BYTES}}", "8589934592"},
  48. {"{{TRAFFIC_TOTAL_BYTES}}", "53687091200"},
  49. {"{{UP}}", "5.00GB"},
  50. {"{{DOWN}}", "3.00GB"},
  51. {"{{STATUS}}", "active"},
  52. {"{{EXPIRE_UNIX}}", "0"}, // no expiry
  53. {"{{EXPIRE_DATE}}", ""}, // no fixed date
  54. {"{{UNKNOWN_TOKEN}}", ""}, // unknown → empty, never literal
  55. {"DE {{EMAIL}} ok", "DE [email protected] ok"},
  56. {"{{EMAIL}}-{{SHORT_ID}}", "[email protected]"},
  57. {"no tokens here", "no tokens here"},
  58. }
  59. for _, c := range cases {
  60. if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
  61. t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
  62. }
  63. }
  64. // The unlimited tokens still render ∞ at the value layer; expandRemarkVars
  65. // is what drops an all-unlimited segment (see TestExpandRemarkVars_DropUnlimitedSegments).
  66. if got := remarkVarValue("DAYS_LEFT", ctx); got != "∞" {
  67. t.Errorf("remarkVarValue(DAYS_LEFT) = %q, want ∞", got)
  68. }
  69. }
  70. func TestExpandRemarkVars_EdgeCases(t *testing.T) {
  71. // Unlimited total → ∞ for human forms, 0 bytes for *_BYTES left. Checked at
  72. // the value layer: expandRemarkVars would drop a bare ∞ segment.
  73. unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 0, Up: gb}, nil)
  74. if got := remarkVarValue("TRAFFIC_TOTAL", unlimited); got != "∞" {
  75. t.Errorf("unlimited TRAFFIC_TOTAL = %q, want ∞", got)
  76. }
  77. if got := remarkVarValue("TRAFFIC_LEFT", unlimited); got != "∞" {
  78. t.Errorf("unlimited TRAFFIC_LEFT = %q, want ∞", got)
  79. }
  80. if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", unlimited); got != "0" {
  81. t.Errorf("unlimited TRAFFIC_LEFT_BYTES = %q, want 0", got)
  82. }
  83. // TgID zero → empty.
  84. if got := expandRemarkVars("{{TELEGRAM_ID}}", unlimited); got != "" {
  85. t.Errorf("zero TgID = %q, want empty", got)
  86. }
  87. // Over-quota usage clamps left to 0, not negative.
  88. over := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: gb, Up: 2 * gb}, nil)
  89. if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", over); got != "0" {
  90. t.Errorf("over-quota TRAFFIC_LEFT_BYTES = %q, want 0", got)
  91. }
  92. // Delayed-start (negative expiry) gives deterministic whole days.
  93. delayed := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, ExpiryTime: -864_000_000}, nil)
  94. if got := expandRemarkVars("{{DAYS_LEFT}}", delayed); got != "10" {
  95. t.Errorf("delayed-start DAYS_LEFT = %q, want 10", got)
  96. }
  97. }
  98. // An unlimited client drops the quota/expiry segments whole — decoration and the
  99. // "|" separator included — instead of printing "📊∞|⏳∞D".
  100. func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
  101. const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
  102. inbound := &model.Inbound{Remark: "host"}
  103. // No limit at all → only the name segment survives.
  104. unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
  105. if got := expandRemarkVars(tmpl, unlimited); got != "host" {
  106. t.Errorf("fully unlimited = %q, want %q", got, "host")
  107. }
  108. // Limited traffic but no expiry → traffic stays, the expiry segment drops.
  109. noExpiry := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 50 * gb, Up: 8 * gb}, inbound)
  110. if got := expandRemarkVars(tmpl, noExpiry); got != "host|📊42.00GB" {
  111. t.Errorf("no-expiry = %q, want %q", got, "host|📊42.00GB")
  112. }
  113. // A segment mixing an unlimited token with another value is kept whole,
  114. // decoration and ∞ included — only all-unlimited segments drop.
  115. mixed := expandCtx(model.Client{Email: "john"}, xray.ClientTraffic{Enable: true}, inbound)
  116. if got := expandRemarkVars("{{EMAIL}} 📊{{TRAFFIC_LEFT}}", mixed); got != "john 📊∞" {
  117. t.Errorf("mixed segment = %q, want %q", got, "john 📊∞")
  118. }
  119. }
  120. func TestClientStatus(t *testing.T) {
  121. cases := []struct {
  122. name string
  123. st xray.ClientTraffic
  124. want string
  125. }{
  126. {"disabled", xray.ClientTraffic{Enable: false}, "disabled"},
  127. {"active", xray.ClientTraffic{Enable: true}, "active"},
  128. {"expired", xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "expired"}, // 1s past epoch
  129. {"depleted", xray.ClientTraffic{Enable: true, Total: gb, Up: gb}, "depleted"},
  130. }
  131. for _, c := range cases {
  132. if got := clientStatus(c.st); got != c.want {
  133. t.Errorf("%s: clientStatus = %q, want %q", c.name, got, c.want)
  134. }
  135. }
  136. }
  137. // hostRemarkService builds a SubService + inbound + client/stats for remark tests.
  138. func hostRemarkService(template string) (*SubService, *model.Inbound, model.Client) {
  139. s := &SubService{remarkTemplate: template, subscriptionBody: true}
  140. inbound := &model.Inbound{
  141. Remark: "DE",
  142. ClientStats: []xray.ClientTraffic{{
  143. Email: "[email protected]",
  144. Enable: true,
  145. Total: 100 * gb,
  146. Up: 15 * gb,
  147. Down: 5 * gb,
  148. ExpiryTime: -864_000_000, // delayed-start: deterministic 10 days
  149. }},
  150. }
  151. client := model.Client{Email: "[email protected]"}
  152. return s, inbound, client
  153. }
  154. // The config name prefers the host endpoint's own remark; the inbound's remark is
  155. // the fallback, used only when the host has none.
  156. func TestGenHostRemark_ConfigNameHostWins(t *testing.T) {
  157. s, inbound, client := hostRemarkService("") // no template → config name only
  158. if got := s.genHostRemark(inbound, client, "Relay"); got != "Relay" {
  159. t.Fatalf("genHostRemark = %q, want %q (host remark wins)", got, "Relay")
  160. }
  161. if got := s.genHostRemark(inbound, client, ""); got != "DE" {
  162. t.Fatalf("genHostRemark (no host remark) = %q, want %q (inbound fallback)", got, "DE")
  163. }
  164. }
  165. // In the body the template applies: {{INBOUND}} is the config name (host remark
  166. // first, inbound fallback) and {{HOST}} is always the host's own remark.
  167. func TestGenHostRemark_GlobalTemplate(t *testing.T) {
  168. // Host remark set → {{INBOUND}} resolves to it (host wins over the inbound).
  169. s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
  170. if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN | 80.00GB | 10d" {
  171. t.Fatalf("global template (host wins) = %q", got)
  172. }
  173. // No host remark → {{INBOUND}} falls back to the inbound's own remark.
  174. s2, inbound2, client2 := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}}")
  175. if got := s2.genHostRemark(inbound2, client2, ""); got != "DE | 80.00GB" {
  176. t.Fatalf("global template (inbound fallback) = %q", got)
  177. }
  178. // {{HOST}} is the host's own remark even when the inbound has one of its own.
  179. s3, inbound3, client3 := hostRemarkService("{{HOST}}")
  180. if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
  181. t.Fatalf("{{HOST}} token = %q, want CDN", got)
  182. }
  183. }
  184. // A global template also drives non-host links via genRemark; {{HOST}} = the
  185. // legacy externalProxy remark passed as extra.
  186. func TestGenRemark_GlobalTemplate(t *testing.T) {
  187. s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
  188. got := s.genRemark(inbound, "[email protected]", "")
  189. if got != "[email protected] | 80.00GB" {
  190. t.Fatalf("global template (non-host) = %q", got)
  191. }
  192. }
  193. // With no template, genRemark composes the fallback model and adds no suffix.
  194. func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
  195. s, inbound, _ := hostRemarkService("")
  196. got := s.genRemark(inbound, "[email protected]", "Relay")
  197. if got != "DE-Relay" {
  198. t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
  199. }
  200. }
  201. // The per-client info part of the template renders only on a client's first
  202. // link of the request; later links show the name-only template.
  203. func TestUsageOnFirstLinkOnly(t *testing.T) {
  204. s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
  205. first := s.genHostRemark(inbound, client, "")
  206. second := s.genHostRemark(inbound, client, "")
  207. if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
  208. t.Fatalf("first link should carry usage: %q", first)
  209. }
  210. if strings.ContainsAny(second, "📊⏳") {
  211. t.Fatalf("second link must not carry usage: %q", second)
  212. }
  213. if second != "DE" {
  214. t.Fatalf("second link = %q, want name-only %q", second, "DE")
  215. }
  216. }
  217. // Outside the subscription body (panel link/QR displays, sub info page) the
  218. // template is bypassed entirely — links show just the config name, with no
  219. // per-client email or usage info.
  220. func TestRemarkInDisplayContext(t *testing.T) {
  221. s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
  222. s.subscriptionBody = false
  223. // A host link in a display shows only the config name — host remark wins, with
  224. // no per-client email or usage info.
  225. if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN" {
  226. t.Fatalf("display host link = %q, want config name %q (host wins)", got, "CDN")
  227. }
  228. // With no host remark, the config name is the inbound's own remark.
  229. if got := s.genHostRemark(inbound, client, ""); got != "DE" {
  230. t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
  231. }
  232. // genRemark (non-host) likewise drops the template in display context.
  233. if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
  234. t.Fatalf("display genRemark = %q, want %q", got, "DE")
  235. }
  236. }
  237. // nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
  238. func TestNameOnlyTemplate(t *testing.T) {
  239. cases := map[string]string{
  240. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // the default → name only
  241. "{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
  242. "{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
  243. "{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
  244. "{{TRAFFIC_LEFT}}": "", // info only → empty
  245. }
  246. for tmpl, want := range cases {
  247. if got := nameOnlyTemplate(tmpl); got != want {
  248. t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
  249. }
  250. }
  251. }
  252. // Two clients through the same global template get distinct, per-client remarks.
  253. func TestGenHostRemark_PerClient(t *testing.T) {
  254. s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
  255. inbound := &model.Inbound{}
  256. a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
  257. b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
  258. if a != "alice@x" || b != "bob@x" {
  259. t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
  260. }
  261. }