Files
3x-ui/internal/web/service/node_test.go
T
MHSanaei 448e8c97c2 fix(node): match prefixed central tags when filtering a selected-mode node snapshot
FilterNodeSnapshot compared a node snapshot's inbound tags against the
raw selected-tag list with an exact match, while its two siblings
(SnapshotHasUnadoptedInbounds and the reconcile tagToCentral map) expand
each selected tag to both its bare node-side form and its n<id>- prefixed
central form. A panel-created node inbound is recorded in the selected
list under the central prefixed tag but reported by the node under the
bare tag, so the exact match dropped it from every snapshot and the
orphan sweep then deleted its central row one tick after creation. Expand
the allowed set with the same prefix flip the siblings use.
2026-07-14 23:32:03 +02:00

238 lines
6.3 KiB
Go

package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func TestNormalizeBasePath(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "/"},
{" ", "/"},
{"/", "/"},
{"/panel", "/panel/"},
{"panel", "/panel/"},
{"panel/", "/panel/"},
{"/panel/", "/panel/"},
{" /panel ", "/panel/"},
{"/a/b/c", "/a/b/c/"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
got := normalizeBasePath(c.in)
if got != c.want {
t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
func TestNodeMetricKey(t *testing.T) {
cases := []struct {
id int
metric string
want string
}{
{1, "cpu", "node:1:cpu"},
{42, "mem", "node:42:mem"},
{0, "anything", "node:0:anything"},
}
for _, c := range cases {
got := nodeMetricKey(c.id, c.metric)
if got != c.want {
t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
}
}
}
func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
p := HeartbeatPatch{
Status: "ignored-source",
LatencyMs: 42,
XrayVersion: "1.8.4",
PanelVersion: "3.0.0",
CpuPct: 12.5,
MemPct: 33.3,
UptimeSecs: 12345,
LastError: "",
}
ui := p.ToUI(true)
if ui.Status != "online" {
t.Fatalf("Status = %q, want online", ui.Status)
}
if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
t.Fatalf("scalar copy mismatch: %+v", ui)
}
if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
t.Fatalf("metric copy mismatch: %+v", ui)
}
if ui.Error != "" {
t.Fatalf("Error = %q, want empty", ui.Error)
}
}
func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
p := HeartbeatPatch{LastError: "connection refused"}
ui := p.ToUI(false)
if ui.Status != "offline" {
t.Fatalf("Status = %q, want offline", ui.Status)
}
if ui.Error != "connection refused" {
t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
}
}
func TestNodeService_Normalize_Valid(t *testing.T) {
s := &NodeService{}
n := &model.Node{
Name: " primary ",
ApiToken: " abc ",
Address: "example.com",
Port: 8443,
Scheme: "",
BasePath: "panel",
}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.Name != "primary" {
t.Fatalf("Name not trimmed: %q", n.Name)
}
if n.ApiToken != "abc" {
t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
}
if n.Scheme != "https" {
t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
}
if n.BasePath != "/panel/" {
t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
}
}
func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.Scheme != "http" {
t.Fatalf("Scheme = %q, want http", n.Scheme)
}
}
func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: " ", Address: "example.com", Port: 443}
if err := s.normalize(n); err == nil {
t.Fatal("expected error for empty name")
}
}
func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
if err := s.normalize(n); err == nil {
t.Fatal("expected error for invalid host")
}
}
func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
s := &NodeService{}
for _, port := range []int{0, -1, 65536, 100000} {
n := &model.Node{Name: "n", Address: "example.com", Port: port}
if err := s.normalize(n); err == nil {
t.Fatalf("expected error for port %d", port)
}
}
}
func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.Scheme != "https" {
t.Fatalf("Scheme = %q, want https", n.Scheme)
}
}
func TestNodeService_NormalizeInboundSelection(t *testing.T) {
s := &NodeService{}
n := &model.Node{
Name: "n",
Address: "example.com",
Port: 443,
InboundSyncMode: "selected",
InboundTags: []string{" alpha ", "", "beta", "alpha"},
}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.InboundSyncMode != "selected" {
t.Fatalf("InboundSyncMode = %q, want selected", n.InboundSyncMode)
}
if len(n.InboundTags) != 2 || n.InboundTags[0] != "alpha" || n.InboundTags[1] != "beta" {
t.Fatalf("InboundTags = %#v, want [alpha beta]", n.InboundTags)
}
}
func TestFilterNodeSnapshot(t *testing.T) {
snapshot := func() *runtime.TrafficSnapshot {
return &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
{Tag: "alpha"},
{Tag: "beta"},
{Tag: "gamma"},
}}
}
all := snapshot()
FilterNodeSnapshot(&model.Node{InboundSyncMode: "all"}, all)
if len(all.Inbounds) != 3 {
t.Fatalf("all mode kept %d inbounds, want 3", len(all.Inbounds))
}
selected := snapshot()
FilterNodeSnapshot(&model.Node{
InboundSyncMode: "selected",
InboundTags: []string{"beta"},
}, selected)
if len(selected.Inbounds) != 1 || selected.Inbounds[0].Tag != "beta" {
t.Fatalf("selected mode produced %#v, want only beta", selected.Inbounds)
}
none := snapshot()
FilterNodeSnapshot(&model.Node{InboundSyncMode: "selected"}, none)
if len(none.Inbounds) != 0 {
t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds))
}
}
func TestFilterNodeSnapshotMatchesPrefixedSelectedTag(t *testing.T) {
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
{Tag: "in-100-tcp"},
{Tag: "in-443-tcp"},
}}
FilterNodeSnapshot(&model.Node{
Id: 5,
InboundSyncMode: "selected",
InboundTags: []string{"in-100-tcp", "n5-in-443-tcp"},
}, snap)
kept := make(map[string]bool, len(snap.Inbounds))
for _, ib := range snap.Inbounds {
kept[ib.Tag] = true
}
if !kept["in-443-tcp"] {
t.Fatalf("node-side tag in-443-tcp filtered out despite the prefixed central tag being selected; kept=%v", kept)
}
if !kept["in-100-tcp"] {
t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept)
}
}