Files
3x-ui/internal/sub/clash_info_node_test.go
T
Rouzbeh† 9f76a66dcf feat(sub): add dummy info node and status configs for subscriptions (#6412)
* feat(settings): add subInfoNodeEnable and status template settings

* feat(sub): add dummy info node and status configs for raw links

* feat(sub): support dummy info node in clash and json subscriptions

* feat(ui): add subscription info node switch and status templates to settings

* style: apply gofumpt formatting

* fix(sub): address review feedback on subscription info node

- Restore GetSubs contract to avoid unintended remark expansions on non-subscription-body calls.
- Exclude dummy info node from Clash PROXY select group when active server nodes exist.
- Track hasEnabledClient and set traffic.Enable in JSON and Clash paths so status tokens evaluate correctly.
- Deterministically sort client emails across subscriptions before selecting primaryEmail.
- Consolidate duplicated info-node evaluation logic into resolveInfoNodeRemark helper.
- Remove redundant pure-getter test from setting_sub_info_node_test.go.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 17:14:01 +02:00

249 lines
7.4 KiB
Go

package sub
import (
"fmt"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestSubClash_InfoNode_Active(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "user1@test.com",
SubID: "sub-clash",
UUID: "c1-uuid",
Enable: true,
TotalGB: 10737418240,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "user1@test.com",
Up: 1073741824,
Down: 1073741824,
Total: 10737418240,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
sub.subInfoNodeEnable = true
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
}
if !strings.Contains(out, "user1@test.com|📊8.00GB") {
t.Fatalf("expected expanded remark on dummy proxy, got:\n%s", out)
}
}
func TestSubClash_InfoNode_Expired(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-clash-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "exp@test.com",
SubID: "sub-clash-exp",
UUID: "c1-uuid",
Enable: true,
ExpiryTime: expiredTime,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "exp@test.com",
ExpiryTime: expiredTime,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
}
if !strings.Contains(out, "Expired") || !strings.Contains(out, "exp@test.com") {
t.Fatalf("expected expired remark in clash YAML, got:\n%s", out)
}
if strings.Contains(out, "Germany-VLESS") {
t.Fatalf("expired subscription must NOT contain working inbound, got:\n%s", out)
}
}
func TestSubClash_InfoNode_Depleted(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-clash-dep","enable":true,"totalGB":5368709120}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "dep@test.com",
SubID: "sub-clash-dep",
UUID: "c1-uuid",
Enable: true,
TotalGB: 5368709120,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "dep@test.com",
Up: 3221225472,
Down: 2147483648,
Total: 5368709120,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
}
if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "dep@test.com") {
t.Fatalf("expected depleted remark in clash YAML, got:\n%s", out)
}
if strings.Contains(out, "Germany-VLESS") {
t.Fatalf("depleted subscription must NOT contain working inbound, got:\n%s", out)
}
}
func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientRecord{Id: 1, Email: "user1@test.com", SubID: "sub-clash", UUID: "c1-uuid", Enable: true}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "user1@test.com", Enable: true}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}")
sub.subInfoNodeEnable = true
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
// Status token must be rendered as active
if !strings.Contains(out, "active") {
t.Fatalf("expected active status in clash dummy remark, got:\n%s", out)
}
// In proxy-groups, PROXY select group must NOT have the dummy node as first member
// PROXY group proxies should begin with real proxy
if strings.Contains(out, "user1@test.com|active") && strings.Contains(out, "proxies:\n - user1@test.com|active") {
t.Fatalf("PROXY group must NOT contain dummy info node as member, got:\n%s", out)
}
if !strings.Contains(out, "proxies:\n - Germany-VLESS\n - DIRECT") {
t.Fatalf("expected real proxy Germany-VLESS in PROXY group, got:\n%s", out)
}
}