package render import ( "encoding/base64" "encoding/json" "net/url" "reflect" "strings" "testing" "gopkg.in/yaml.v3" "github.com/peterqiu0516/sub-store/internal/model" ) // --- test fixtures --- // allNodeTypes returns one node per supported proxy type, with realistic // fields that exercise every renderer branch. func allNodeTypes() []model.ProxyNode { return []model.ProxyNode{ { "type": "ss", "name": "SS-Node", "server": "ss.example.com", "port": 8388, "cipher": "aes-256-gcm", "password": "ss-pass", "udp": true, "tfo": true, "skip-cert-verify": true, "alpn": []any{"h2", "http/1.1"}, "plugin": "obfs", "plugin-opts": map[string]any{"mode": "http", "host": "obfs.example.com", "path": "/obfs"}, }, { "type": "vmess", "name": "VMess-Node", "server": "vmess.example.com", "port": 443, "uuid": "vmess-uuid", "alterId": 64, "cipher": "auto", "network": "ws", "tls": true, "servername": "vmess.sni.com", "udp": true, "ws-opts": map[string]any{ "path": "/vmess", "headers": map[string]any{"Host": "vmess.example.com"}, }, }, { "type": "vless", "name": "VLESS-Node", "server": "vless.example.com", "port": 443, "uuid": "vless-uuid", "network": "ws", "tls": true, "servername": "vless.sni.com", "flow": "xtls-rprx-vision", "client-fingerprint": "chrome", "reality-opts": map[string]any{ "public-key": "realitypubkey", "short-id": "abc123", "spider-x": "/spider", }, }, { "type": "trojan", "name": "Trojan-Node", "server": "trojan.example.com", "port": 443, "password": "trojan-pass", "sni": "trojan.sni.com", "udp": true, "skip-cert-verify": true, }, { "type": "hysteria2", "name": "Hy2-Node", "server": "hy2.example.com", "port": 443, "password": "hy2-pass", "sni": "hy2.sni.com", "obfs": "salamander", "obfs-password": "obfs-pwd", "skip-cert-verify": true, }, { "type": "tuic", "name": "TUIC-Node", "server": "tuic.example.com", "port": 443, "uuid": "tuic-uuid", "password": "tuic-pass", "sni": "tuic.sni.com", "udp-relay-mode": "quic", "congestion-controller": "bbr", "reduce-rtt": true, "disable-sni": true, "skip-cert-verify": true, }, { "type": "http", "name": "HTTP-Node", "server": "http.example.com", "port": 8080, "username": "http-user", "password": "http-pass", "tls": true, "sni": "http.sni.com", }, { "type": "socks5", "name": "SOCKS5-Node", "server": "socks.example.com", "port": 1080, "username": "socks-user", "password": "socks-pass", "tls": true, }, { "type": "anytls", "name": "AnyTLS-Node", "server": "anytls.example.com", "port": 443, "password": "anytls-pass", "sni": "anytls.sni.com", "skip-cert-verify": true, "client-fingerprint": "chrome", }, { "type": "wireguard", "name": "WG-Node", "server": "wg.example.com", "port": 51820, "private-key": "wg-privkey", "public-key": "wg-pubkey", "pre-shared-key": "wg-psk", "ip": "10.0.0.2/32", "ipv6": "fd00::2/128", "reserved": "1,2,3", }, { "type": "snell", "name": "Snell-Node", "server": "snell.example.com", "port": 443, "psk": "snell-psk", "version": 4, }, { "type": "ssh", "name": "SSH-Node", "server": "ssh.example.com", "port": 22, "username": "ssh-user", "password": "ssh-pass", "private-key": "ssh-privkey", }, { "type": "h2-connect", "name": "H2-Node", "server": "h2.example.com", "port": 443, "username": "h2-user", "password": "h2-pass", "tls": true, "sni": "h2.sni.com", }, { "type": "ssr", "name": "SSR-Node", "server": "ssr.example.com", "port": 8388, "cipher": "aes-256-cfb", "password": "ssr-pass", "protocol": "auth_aes128_md5", "obfs": "tls1.2_ticket_auth", "protocol-param": "param1", "obfs-param": "param2", }, { "type": "hysteria", "name": "Hy-Node", "server": "hy.example.com", "port": 443, "auth_str": "hy-auth", "up": 100, "down": 200, "sni": "hy.sni.com", "protocol": "udp", "obfs": "salamander", "obfs-password": "hy-obfs", "skip-cert-verify": true, }, } } // nodeByName extracts a single node from allNodeTypes by name prefix. // Matches the first node whose name starts with the given prefix. func nodeByName(name string) model.ProxyNode { for _, n := range allNodeTypes() { if strings.HasPrefix(getString(n, "name"), name) { return n } } return nil } // nodeByExactName extracts a single node from allNodeTypes by exact name match. func nodeByExactName(name string) model.ProxyNode { for _, n := range allNodeTypes() { if getString(n, "name") == name { return n } } return nil } // --- dispatch: RenderTarget coverage --- func TestExtraRenderTargetAll(t *testing.T) { nodes := allNodeTypes() targets := []string{ model.TargetMihomo, model.TargetStash, model.TargetSurge, model.TargetSurgeMac, model.TargetSurfboard, model.TargetLoon, model.TargetEgern, model.TargetQX, model.TargetSingBox, model.TargetV2ray, model.TargetURI, model.TargetShadowrocket, model.TargetJSON, } for _, tgt := range targets { t.Run(tgt, func(t *testing.T) { out, err := RenderTarget(nodes, tgt, nil) // Every target must either succeed or error with no-nodes; // since we have 15 nodes, success is expected for all. if err != nil { // Surge/surfboard may reject unsupported types, but at least // one node should be supported — so err here is unexpected. t.Fatalf("RenderTarget(%q) returned error: %v", tgt, err) } if strings.TrimSpace(out) == "" { t.Fatalf("RenderTarget(%q) returned empty output", tgt) } }) } } func TestExtraRenderTargetEmptyNodes(t *testing.T) { empty := []model.ProxyNode{} for _, tgt := range []string{ model.TargetMihomo, model.TargetSurge, model.TargetSingBox, model.TargetV2ray, model.TargetURI, model.TargetJSON, model.TargetLoon, model.TargetEgern, model.TargetQX, model.TargetSurfboard, model.TargetSurgeMac, } { t.Run(tgt, func(t *testing.T) { _, err := RenderTarget(empty, tgt, nil) if err == nil { t.Fatalf("expected error for empty nodes with target %q", tgt) } }) } } func TestExtraRenderTargetUnsupportedFallsBackToMihomo(t *testing.T) { nodes := allNodeTypes()[:2] out, err := RenderTarget(nodes, "no-such-target", nil) if err != nil { t.Fatalf("unsupported target should fall back to mihomo, got err: %v", err) } if !strings.Contains(out, "Generated by Sub-Store") { t.Errorf("expected mihomo fallback output, got: %s", out) } } func TestExtraRenderBuildTargetAll(t *testing.T) { nodes := allNodeTypes() for _, tgt := range []string{ model.TargetMihomo, model.TargetStash, model.TargetSurge, model.TargetSurgeMac, model.TargetSurfboard, model.TargetLoon, model.TargetEgern, model.TargetShadowrocket, model.TargetQX, model.TargetSingBox, model.TargetV2ray, model.TargetURI, model.TargetJSON, } { t.Run(tgt, func(t *testing.T) { out, err := RenderBuildTarget(nodes, tgt, "https://sub.example.com/api/sub?target=x", nil) if err != nil { t.Fatalf("RenderBuildTarget(%q) error: %v", tgt, err) } if strings.TrimSpace(out) == "" { t.Fatalf("RenderBuildTarget(%q) empty output", tgt) } }) } } func TestExtraRenderBuildTargetFallback(t *testing.T) { nodes := allNodeTypes()[:1] out, err := RenderBuildTarget(nodes, "unknown-target", "https://sub.example.com/path", nil) if err != nil { t.Fatalf("fallback should succeed: %v", err) } if !strings.Contains(out, "/path") { t.Errorf("expected requestUrl path /path in output, got: %s", out) } } func TestExtraSourcePath(t *testing.T) { cases := []struct { in, want string }{ {"https://sub.example.com/api/sub", "/api/sub"}, {"https://sub.example.com", "sub.example.com"}, {"not-a-url", "not-a-url"}, {"", ""}, {"https://sub.example.com/path?a=b", "/path"}, } for _, c := range cases { got := sourcePath(c.in) if got != c.want { t.Errorf("sourcePath(%q) = %q, want %q", c.in, got, c.want) } } } // --- Surge renderer --- func TestExtraSurgeProxyLineAllTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode wantSub []string }{ {"ss", nodeByName("SS"), []string{"SS-Node", "=ss,"}}, {"vmess", nodeByName("VMess"), []string{"=vmess,", "vmess-uuid", "encrypt-method=auto"}}, {"trojan", nodeByName("Trojan"), []string{"=trojan,", "trojan-pass"}}, {"http", nodeByName("HTTP"), []string{"=https,", "http.example.com", "8080"}}, {"socks5-tls", nodeByName("SOCKS5"), []string{"=socks5-tls,"}}, {"hysteria2", nodeByName("Hy2"), []string{"=hysteria2,", "hy2-pass", "obfs=salamander"}}, {"tuic", nodeByName("TUIC"), []string{"=tuic-v5,", "tuic-uuid"}}, {"anytls", nodeByName("AnyTLS"), []string{"=anytls,"}}, {"snell", nodeByName("Snell"), []string{"=snell,", "snell-psk", "version=4"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { line := ToSurgeProxyLine(c.node) if line == "" { t.Fatalf("ToSurgeProxyLine(%s) returned empty", c.name) } for _, sub := range c.wantSub { if !strings.Contains(line, sub) { t.Errorf("expected %q in line: %s", sub, line) } } }) } } func TestExtraSurgeUnsupportedType(t *testing.T) { node := model.ProxyNode{"type": "wireguard", "name": "wg", "server": "x", "port": 1} if line := ToSurgeProxyLine(node); line != "" { t.Errorf("expected empty for unsupported type, got: %s", line) } } func TestExtraSurgeSocks5WithoutTls(t *testing.T) { node := model.ProxyNode{ "type": "socks5", "name": "socks", "server": "s", "port": 1080, "username": "u", "password": "p", "tls": false, } line := ToSurgeProxyLine(node) if !strings.Contains(line, "=socks5,") { t.Errorf("expected socks5 (not socks5-tls): %s", line) } } func TestExtraSurgeHTTPWithoutTls(t *testing.T) { node := model.ProxyNode{ "type": "http", "name": "h", "server": "s", "port": 80, "tls": false, } line := ToSurgeProxyLine(node) if !strings.Contains(line, "=http,") { t.Errorf("expected http (not https): %s", line) } } func TestExtraSurgeHysteria2NoObfs(t *testing.T) { node := model.ProxyNode{ "type": "hysteria2", "name": "hy2", "server": "s", "port": 443, "password": "p", "sni": "sni.com", } line := ToSurgeProxyLine(node) if strings.Contains(line, "obfs=") { t.Errorf("expected no obfs in line: %s", line) } } func TestExtraSurgeSnellDefaultVersion(t *testing.T) { node := model.ProxyNode{ "type": "snell", "name": "snell", "server": "s", "port": 443, "password": "p", } line := ToSurgeProxyLine(node) if !strings.Contains(line, "version=3") { t.Errorf("expected default version=3: %s", line) } } func TestExtraSurgeSnellPskFallback(t *testing.T) { node := model.ProxyNode{ "type": "snell", "name": "snell", "server": "s", "port": 443, "password": "fallback-pwd", } line := ToSurgeProxyLine(node) if !strings.Contains(line, "psk=fallback-pwd") { t.Errorf("expected psk=fallback-pwd: %s", line) } } // --- Surge-Mac renderer --- func TestExtraSurgeMacSpecificTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode want []string }{ {"ssh", nodeByName("SSH"), []string{"=ssh,", "ssh.example.com", "ssh-user", "private-key=ssh-privkey"}}, {"h2-connect", nodeByName("H2"), []string{"=h2-connect,", "h2.example.com", "tls=true"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { line := ToSurgeMacProxyLine(c.node) if line == "" { t.Fatalf("ToSurgeMacProxyLine(%s) returned empty", c.name) } for _, w := range c.want { if !strings.Contains(line, w) { t.Errorf("expected %q in line: %s", w, line) } } }) } } func TestExtraSurgeMacFallsBackToSurge(t *testing.T) { node := nodeByName("SS") macLine := ToSurgeMacProxyLine(node) surgeLine := ToSurgeProxyLine(node) if macLine != surgeLine { t.Errorf("surge-mac should delegate ss to surge; mac=%q surge=%q", macLine, surgeLine) } } func TestExtraRenderSurgeMacProxies(t *testing.T) { nodes := allNodeTypes() out, err := RenderSurgeMacProxies(nodes) if err != nil { t.Fatalf("RenderSurgeMacProxies failed: %v", err) } if !strings.Contains(out, "SSH-Node=ssh,") { t.Errorf("expected ssh line in surge-mac output: %s", out) } if !strings.Contains(out, "H2-Node=h2-connect,") { t.Errorf("expected h2-connect line in surge-mac output: %s", out) } } // --- Surfboard renderer --- func TestExtraSurfboardProxies(t *testing.T) { nodes := allNodeTypes() out, err := RenderSurfboardProxies(nodes) if err != nil { t.Fatalf("RenderSurfboardProxies failed: %v", err) } if !strings.Contains(out, "SS-Node") { t.Errorf("expected SS-Node in surfboard output: %s", out) } // Surfboard does not support hysteria2/tuic/wireguard etc. if strings.Contains(out, "Hy2-Node") { t.Errorf("surfboard should not include hysteria2: %s", out) } } func TestExtraSurfboardEmptyNodes(t *testing.T) { _, err := RenderSurfboardProxies([]model.ProxyNode{}) if err == nil { t.Fatal("expected error for empty nodes") } } // --- Loon renderer --- func TestExtraLoonProxyLineAllTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode wantSub []string }{ {"ss", nodeByName("SS"), []string{"=shadowsocks,", "ss.example.com", "8388"}}, {"ssr", nodeByName("SSR"), []string{"=shadowsocksr,"}}, {"vmess", nodeByName("VMess"), []string{"=vmess,", "vmess-uuid"}}, {"vless", nodeByName("VLESS"), []string{"=vless,", "vless-uuid"}}, {"trojan", nodeByName("Trojan"), []string{"=trojan,"}}, {"anytls", nodeByName("AnyTLS"), []string{"=anytls,"}}, {"http", nodeByName("HTTP"), []string{"=http,"}}, {"socks5", nodeByName("SOCKS5"), []string{"=socks5,"}}, {"hysteria2", nodeByName("Hy2"), []string{"=Hysteria2,"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { line := ToLoonProxyLine(c.node) if line == "" { t.Fatalf("ToLoonProxyLine(%s) empty", c.name) } for _, sub := range c.wantSub { if !strings.Contains(line, sub) { t.Errorf("expected %q in: %s", sub, line) } } }) } } func TestExtraLoonUnsupportedType(t *testing.T) { node := model.ProxyNode{"type": "tuic", "name": "x", "server": "s", "port": 1} if line := ToLoonProxyLine(node); line != "" { t.Errorf("tuic should be unsupported in loon, got: %s", line) } } func TestExtraLoonVlessMethodNone(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "vl", "server": "s", "port": 443, "uuid": "u", } line := ToLoonProxyLine(node) if !strings.Contains(line, ",none,") { t.Errorf("expected method=none for vless: %s", line) } } func TestExtraLoonSsDefaultCipher(t *testing.T) { node := model.ProxyNode{ "type": "ss", "name": "x", "server": "s", "port": 1, "password": "p", } line := ToLoonProxyLine(node) if !strings.Contains(line, ",none,") { t.Errorf("expected default cipher=none: %s", line) } } // --- QX renderer --- func TestExtraQxProxyLineAllTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode wantSub []string }{ {"ss", nodeByName("SS"), []string{"shadowsocks=ss.example.com:8388", "tag=SS-Node"}}, {"ssr", nodeByName("SSR"), []string{"shadowsocks=ssr.example.com:8388", "ssr-protocol=auth_aes128_md5"}}, {"vmess", nodeByName("VMess"), []string{"vmess=vmess.example.com:443", "method=auto"}}, {"vless", nodeByName("VLESS"), []string{"vless=vless.example.com:443", "method=none"}}, {"trojan", nodeByName("Trojan"), []string{"trojan=trojan.example.com:443", "over-tls=true"}}, {"anytls", nodeByName("AnyTLS"), []string{"anytls=anytls.example.com:443"}}, {"http", nodeByName("HTTP"), []string{"http=http.example.com:8080"}}, {"socks5", nodeByName("SOCKS5"), []string{"socks5=socks.example.com:1080"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { line := ToQxProxyLine(c.node) if line == "" { t.Fatalf("ToQxProxyLine(%s) empty", c.name) } for _, sub := range c.wantSub { if !strings.Contains(line, sub) { t.Errorf("expected %q in: %s", sub, line) } } }) } } func TestExtraQxUnsupportedType(t *testing.T) { node := model.ProxyNode{"type": "wireguard", "name": "x", "server": "s", "port": 1} if line := ToQxProxyLine(node); line != "" { t.Errorf("wireguard should be unsupported in qx, got: %s", line) } } func TestExtraQxWsTransportWss(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "n", "server": "s", "port": 443, "uuid": "u", "network": "ws", "tls": true, "ws-opts": map[string]any{"path": "/ws", "headers": map[string]any{"Host": "h.com"}}, } line := ToQxProxyLine(node) if !strings.Contains(line, "obfs=wss") { t.Errorf("expected obfs=wss for tls+ws: %s", line) } } func TestExtraQxWsTransportWs(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "n", "server": "s", "port": 80, "uuid": "u", "network": "ws", "tls": false, "ws-opts": map[string]any{"path": "/ws"}, } line := ToQxProxyLine(node) if !strings.Contains(line, "obfs=ws") { t.Errorf("expected obfs=ws for plain ws: %s", line) } } func TestExtraQxEmptyName(t *testing.T) { node := model.ProxyNode{ "type": "ss", "name": "", "server": "s", "port": 1, "cipher": "c", "password": "p", } line := ToQxProxyLine(node) if !strings.Contains(line, "tag=proxy") { t.Errorf("expected tag=proxy for empty name: %s", line) } } func TestExtraSanitizeQxTag(t *testing.T) { cases := []struct{ in, want string }{ {"name,with,commas", "name with commas"}, {"name\r\n", "name"}, {"=", "="}, // = is NOT replaced in qx tag {"", "proxy"}, } for _, c := range cases { got := sanitizeQxTag(c.in) if got != c.want { t.Errorf("sanitizeQxTag(%q) = %q, want %q", c.in, got, c.want) } } } // --- Egern renderer --- func TestExtraEgernAllTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode wantKey map[string]string }{ {"ss", nodeByName("SS"), map[string]string{"type": "shadowsocks"}}, {"vmess", nodeByName("VMess"), map[string]string{"type": "vmess", "security": "auto"}}, {"vless", nodeByName("VLESS"), map[string]string{"type": "vless"}}, {"trojan", nodeByName("Trojan"), map[string]string{"type": "trojan"}}, {"anytls", nodeByName("AnyTLS"), map[string]string{"type": "anytls"}}, {"hysteria2", nodeByName("Hy2"), map[string]string{"type": "hysteria2"}}, {"http", nodeByName("HTTP"), map[string]string{"type": "https"}}, {"socks5", nodeByName("SOCKS5"), map[string]string{"type": "socks5_tls"}}, {"tuic", nodeByName("TUIC"), map[string]string{"type": "tuic"}}, {"wireguard", nodeByName("WG"), map[string]string{"type": "wireguard", "private_key": "wg-privkey"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { m := ToEgernProxy(c.node) if m == nil { t.Fatalf("ToEgernProxy(%s) nil", c.name) } for k, v := range c.wantKey { got, ok := m[k] if !ok { t.Errorf("missing key %q in egern output: %v", k, m) continue } if s, _ := got.(string); s != v { t.Errorf("egern[%q] = %v, want %q", k, got, v) } } }) } } func TestExtraEgernUnsupportedType(t *testing.T) { node := model.ProxyNode{"type": "snell", "name": "x"} if m := ToEgernProxy(node); m != nil { t.Errorf("snell should be unsupported in egern, got: %v", m) } } func TestExtraEgernHTTPWithoutTls(t *testing.T) { node := model.ProxyNode{ "type": "http", "name": "h", "server": "s", "port": 80, "tls": false, } m := ToEgernProxy(node) if m["type"] != "http" { t.Errorf("expected type=http, got: %v", m["type"]) } } func TestExtraEgernSocks5WithoutTls(t *testing.T) { node := model.ProxyNode{ "type": "socks5", "name": "s", "server": "s", "port": 1, "tls": false, } m := ToEgernProxy(node) if m["type"] != "socks5" { t.Errorf("expected type=socks5, got: %v", m["type"]) } } func TestExtraEgernWsOptions(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "port": 443, "uuid": "u", "network": "ws", "ws-opts": map[string]any{ "path": "/ws", "headers": map[string]any{"Host": "h.com"}, }, } m := ToEgernProxy(node) ws, ok := m["ws_opts"].(map[string]any) if !ok { t.Fatalf("expected ws_opts map, got: %v", m["ws_opts"]) } if ws["path"] != "/ws" { t.Errorf("expected ws path /ws, got: %v", ws["path"]) } if _, ok := ws["headers"]; !ok { t.Errorf("expected headers in ws_opts") } } func TestExtraEgernWsOptionsNoHeaders(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "port": 443, "uuid": "u", "network": "ws", "ws-opts": map[string]any{"path": "/ws"}, } m := ToEgernProxy(node) ws, _ := m["ws_opts"].(map[string]any) if _, ok := ws["headers"]; ok { t.Errorf("expected no headers in ws_opts when absent") } } func TestExtraEgernWsOptionsNonWs(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "port": 443, "uuid": "u", "network": "tcp", } m := ToEgernProxy(node) if _, ok := m["ws_opts"]; ok { t.Errorf("expected no ws_opts for tcp network") } } func TestExtraEgernRealityOptions(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "reality-opts": map[string]any{ "public-key": "pk", "short-id": "sid", }, } m := ToEgernProxy(node) r, ok := m["reality"].(map[string]any) if !ok { t.Fatalf("expected reality map, got: %v", m["reality"]) } if r["public_key"] != "pk" { t.Errorf("expected public_key=pk, got: %v", r["public_key"]) } } func TestExtraEgernRealityNoPubKey(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "reality-opts": map[string]any{"short-id": "sid"}, } m := ToEgernProxy(node) if _, ok := m["reality"]; ok { t.Errorf("expected no reality without public-key") } } func TestExtraEgernRenderYaml(t *testing.T) { nodes := allNodeTypes() out, err := RenderEgernYaml(nodes) if err != nil { t.Fatalf("RenderEgernYaml failed: %v", err) } var parsed map[string]any if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("egern output not valid YAML: %v\n%s", err, out) } proxies, ok := parsed["proxies"].([]any) if !ok { t.Fatal("expected proxies array in egern yaml") } if len(proxies) == 0 { t.Error("expected at least one proxy in egern yaml") } } func TestExtraEgernRenderYamlEmpty(t *testing.T) { _, err := RenderEgernYaml([]model.ProxyNode{}) if err == nil { t.Fatal("expected error for empty egern") } } func TestExtraEgernRenderYamlAllUnsupported(t *testing.T) { nodes := []model.ProxyNode{ {"type": "snell", "name": "x"}, } _, err := RenderEgernYaml(nodes) if err == nil { t.Fatal("expected error when no supported nodes") } } func TestExtraNoNodesError(t *testing.T) { e := errNoNodes("test") if e.Error() != "No supported nodes for test output" { t.Errorf("unexpected error message: %s", e.Error()) } } func TestExtraMergeMap(t *testing.T) { base := map[string]any{"a": 1, "b": 2} next := map[string]any{"b": 3, "c": 4} merged := mergeMap(base, next) if merged["a"] != 1 || merged["b"] != 3 || merged["c"] != 4 { t.Errorf("mergeMap unexpected: %v", merged) } // originals should not be mutated if base["b"] != 2 { t.Errorf("mergeMap mutated base") } } // --- sing-box renderer --- func TestExtraSingBoxStructure(t *testing.T) { nodes := allNodeTypes() out := RenderSingBoxJson(nodes) var parsed map[string]any if err := json.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("sing-box output not valid JSON: %v", err) } // Per review-resolution #42: log, inbounds, outbounds, route for _, key := range []string{"log", "inbounds", "outbounds", "route"} { if _, ok := parsed[key]; !ok { t.Errorf("missing %q in sing-box output", key) } } logMap, _ := parsed["log"].(map[string]any) if logMap == nil || logMap["level"] != "info" { t.Errorf("expected log.level=info, got: %v", parsed["log"]) } inbounds, _ := parsed["inbounds"].([]any) if len(inbounds) == 0 { t.Fatal("expected at least one inbound") } inbound, _ := inbounds[0].(map[string]any) if inbound["type"] != "mixed" || inbound["tag"] != "mixed-in" { t.Errorf("unexpected inbound: %v", inbound) } if inbound["listen_port"] != float64(7890) { t.Errorf("expected listen_port=7890, got: %v", inbound["listen_port"]) } outbounds, _ := parsed["outbounds"].([]any) if len(outbounds) < 3 { t.Fatalf("expected at least 3 outbounds (PROXY, AUTO, nodes, DIRECT), got %d", len(outbounds)) } proxyOut, _ := outbounds[0].(map[string]any) if proxyOut["tag"] != "PROXY" || proxyOut["type"] != "selector" { t.Errorf("expected first outbound PROXY selector, got: %v", proxyOut) } autoOut, _ := outbounds[1].(map[string]any) if autoOut["tag"] != "AUTO" || autoOut["type"] != "urltest" { t.Errorf("expected second outbound AUTO urltest, got: %v", autoOut) } // DIRECT should be at the end (REJECT is now a route rule action, not a special outbound) last, _ := outbounds[len(outbounds)-1].(map[string]any) if last["tag"] != "DIRECT" || last["type"] != "direct" { t.Errorf("expected last outbound DIRECT direct, got: %v", last) } // Inbound should NOT have sniff field (migrated to route rule action in sing-box 1.11+) if _, hasSniff := inbound["sniff"]; hasSniff { t.Errorf("inbound should not have sniff field (migrated to route rule action)") } // Route should have rules with sniff and reject actions routeMap, _ := parsed["route"].(map[string]any) rules, _ := routeMap["rules"].([]any) if len(rules) < 2 { t.Fatalf("expected at least 2 route rules (sniff, reject), got %d", len(rules)) } rule0, _ := rules[0].(map[string]any) if rule0["action"] != "sniff" { t.Errorf("expected first route rule action=sniff, got: %v", rule0["action"]) } rule1, _ := rules[1].(map[string]any) if rule1["action"] != "reject" { t.Errorf("expected second route rule action=reject, got: %v", rule1["action"]) } if routeMap == nil || routeMap["final"] != "PROXY" { t.Errorf("expected route.final=PROXY, got: %v", parsed["route"]) } if routeMap["auto_detect_interface"] != true { t.Errorf("expected auto_detect_interface=true, got: %v", routeMap["auto_detect_interface"]) } } func TestExtraSingBoxOutboundAllTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode wantType string }{ {"ss", nodeByName("SS"), "shadowsocks"}, {"vmess", nodeByName("VMess"), "vmess"}, {"vless", nodeByName("VLESS"), "vless"}, {"trojan", nodeByName("Trojan"), "trojan"}, {"hysteria2", nodeByExactName("Hy2-Node"), "hysteria2"}, {"hysteria", nodeByExactName("Hy-Node"), "hysteria"}, {"anytls", nodeByName("AnyTLS"), "anytls"}, {"tuic", nodeByName("TUIC"), "tuic"}, {"socks5", nodeByName("SOCKS5"), "socks"}, {"http", nodeByName("HTTP"), "http"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { out := ToSingBoxOutbound(c.node) if out == nil { t.Fatalf("ToSingBoxOutbound(%s) nil", c.name) } if out["type"] != c.wantType { t.Errorf("sing-box[%s].type = %v, want %q", c.name, out["type"], c.wantType) } if out["tag"] != getString(c.node, "name") { t.Errorf("sing-box[%s].tag = %v, want %q", c.name, out["tag"], getString(c.node, "name")) } }) } } func TestExtraSingBoxWireGuardEndpoint(t *testing.T) { node := nodeByName("WG") ep := ToSingBoxWireGuardEndpoint(node) if ep == nil { t.Fatalf("ToSingBoxWireGuardEndpoint(WG) nil") } if ep["type"] != "wireguard" { t.Errorf("expected type=wireguard, got %v", ep["type"]) } if ep["tag"] != "WG-Node" { t.Errorf("expected tag=WG-Node, got %v", ep["tag"]) } peers, ok := ep["peers"].([]any) if !ok || len(peers) == 0 { t.Fatalf("expected peers array, got: %v", ep["peers"]) } peer, ok := peers[0].(map[string]any) if !ok { t.Fatalf("expected peer map, got: %v", peers[0]) } if peer["public_key"] == nil { t.Errorf("expected peer.public_key to be set") } } func TestExtraSingBoxUnsupportedType(t *testing.T) { node := model.ProxyNode{"type": "snell", "name": "x"} if out := ToSingBoxOutbound(node); out != nil { t.Errorf("snell should be unsupported in sing-box, got: %v", out) } } func TestExtraSingBoxVlessReality(t *testing.T) { node := nodeByName("VLESS") out := ToSingBoxOutbound(node) tls, ok := out["tls"].(map[string]any) if !ok { t.Fatalf("expected tls map, got: %v", out["tls"]) } reality, ok := tls["reality"].(map[string]any) if !ok { t.Fatalf("expected reality map under tls, got: %v", tls["reality"]) } if reality["public_key"] != "realitypubkey" { t.Errorf("expected reality public_key, got: %v", reality["public_key"]) } } func TestExtraSingBoxVlessNoTls(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "tls": false, } out := ToSingBoxOutbound(node) if _, ok := out["tls"]; ok { t.Errorf("expected no tls field when tls=false") } } func TestExtraSingBoxHysteria2Obfs(t *testing.T) { node := nodeByName("Hy2") out := ToSingBoxOutbound(node) obfs, ok := out["obfs"].(map[string]any) if !ok { t.Fatalf("expected obfs map, got: %v", out["obfs"]) } if obfs["type"] != "salamander" { t.Errorf("expected obfs.type=salamander, got: %v", obfs["type"]) } } func TestExtraSingBoxHysteria2NoObfs(t *testing.T) { node := model.ProxyNode{ "type": "hysteria2", "name": "h", "server": "s", "port": 443, "password": "p", } out := ToSingBoxOutbound(node) if _, ok := out["obfs"]; ok { t.Errorf("expected no obfs field when obfs absent") } } func TestExtraSingBoxSocks5Tls(t *testing.T) { node := nodeByName("SOCKS5") out := ToSingBoxOutbound(node) tls, ok := out["tls"].(map[string]any) if !ok || tls["enabled"] != true { t.Errorf("expected tls.enabled=true for socks5+tls, got: %v", out["tls"]) } } func TestExtraSingBoxSocks5NoTls(t *testing.T) { node := model.ProxyNode{ "type": "socks5", "name": "s", "server": "s", "port": 1, "tls": false, } out := ToSingBoxOutbound(node) if _, ok := out["tls"]; ok { t.Errorf("expected no tls field for plain socks5") } } func TestExtraSingBoxHTTPTls(t *testing.T) { node := nodeByName("HTTP") out := ToSingBoxOutbound(node) tls, ok := out["tls"].(map[string]any) if !ok || tls["enabled"] != true { t.Errorf("expected tls.enabled=true for https, got: %v", out["tls"]) } } func TestExtraSingBoxWireGuardReserved(t *testing.T) { node := nodeByName("WG") ep := ToSingBoxWireGuardEndpoint(node) peers, ok := ep["peers"].([]any) if !ok || len(peers) == 0 { t.Fatalf("expected peers array, got: %v", ep["peers"]) } peer := peers[0].(map[string]any) reserved, ok := peer["reserved"].([]int) if !ok { t.Fatalf("expected peer reserved []int, got: %v", peer["reserved"]) } if !reflect.DeepEqual(reserved, []int{1, 2, 3}) { t.Errorf("expected reserved=[1,2,3], got: %v", reserved) } } func TestExtraSingBoxWireGuardLocalAddress(t *testing.T) { node := nodeByName("WG") ep := ToSingBoxWireGuardEndpoint(node) addr, ok := ep["address"].([]string) if !ok { t.Fatalf("expected address, got: %v", ep["address"]) } if len(addr) != 2 { t.Errorf("expected 2 addresses (ip+ipv6), got %d", len(addr)) } } func TestExtraSingBoxVmessTransport(t *testing.T) { node := nodeByName("VMess") out := ToSingBoxOutbound(node) transport, ok := out["transport"].(map[string]any) if !ok { t.Fatalf("expected transport map, got: %v", out["transport"]) } if transport["type"] != "ws" { t.Errorf("expected transport.type=ws, got: %v", transport["type"]) } } func TestExtraSingBoxVmessNoTransport(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "port": 443, "uuid": "u", "network": "tcp", } out := ToSingBoxOutbound(node) if _, ok := out["transport"]; ok { t.Errorf("expected no transport for tcp vmess") } } func TestExtraSingBoxVmessNoTls(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "port": 443, "uuid": "u", "tls": false, } out := ToSingBoxOutbound(node) if _, ok := out["tls"]; ok { t.Errorf("expected no tls field for plain vmess") } } func TestExtraParseWireGuardReserved(t *testing.T) { cases := []struct { name string input any want []int }{ {"array", []any{1, 2, 3}, []int{1, 2, 3}}, {"array_with_zero", []any{0, 1, 2}, []int{1, 2}}, {"array_empty", []any{}, nil}, {"int_slice", []int{1, 2}, []int{1, 2}}, {"string", "1,2,3", []int{1, 2, 3}}, {"empty_string", "", nil}, {"nil", nil, nil}, {"other_type", 42, nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := parseWireGuardReserved(c.input) if !reflect.DeepEqual(got, c.want) { t.Errorf("parseWireGuardReserved(%v) = %v, want %v", c.input, got, c.want) } }) } } func TestExtraSplitComma(t *testing.T) { cases := []struct { in string want []string }{ {"a,b,c", []string{"a", "b", "c"}}, {"", []string{""}}, {"abc", []string{"abc"}}, {",a", []string{"", "a"}}, {"a,", []string{"a", ""}}, } for _, c := range cases { got := splitComma(c.in) if !reflect.DeepEqual(got, c.want) { t.Errorf("splitComma(%q) = %v, want %v", c.in, got, c.want) } } } // --- URI renderer --- func TestExtraToProxyUriAllTypes(t *testing.T) { cases := []struct { name string node model.ProxyNode scheme string contains string }{ {"ss", nodeByName("SS"), "ss://", ""}, {"ssr", nodeByName("SSR"), "ssr://", ""}, {"vmess", nodeByName("VMess"), "vmess://", ""}, {"vless", nodeByName("VLESS"), "vless://", "vless-uuid"}, {"trojan", nodeByName("Trojan"), "trojan://", "trojan-pass"}, {"hysteria2", nodeByExactName("Hy2-Node"), "hysteria2://", "hy2-pass"}, {"hysteria", nodeByExactName("Hy-Node"), "hysteria://", "hy-auth"}, {"anytls", nodeByName("AnyTLS"), "anytls://", "anytls-pass"}, {"tuic", nodeByName("TUIC"), "tuic://", "tuic-uuid"}, {"socks5", nodeByName("SOCKS5"), "socks5+tls://", ""}, {"http", nodeByName("HTTP"), "https://", ""}, {"wireguard", nodeByName("WG"), "wireguard://", "wg-privkey"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { uri := ToProxyUri(c.node) if uri == "" { t.Fatalf("ToProxyUri(%s) empty", c.name) } if !strings.HasPrefix(uri, c.scheme) { t.Errorf("expected scheme %q, got: %s", c.scheme, uri) } if c.contains != "" && !strings.Contains(uri, c.contains) { t.Errorf("expected %q in uri: %s", c.contains, uri) } }) } } func TestExtraToProxyUriUnsupported(t *testing.T) { node := model.ProxyNode{"type": "snell", "name": "x"} if uri := ToProxyUri(node); uri != "" { t.Errorf("expected empty for snell, got: %s", uri) } } func TestExtraToProxyUriVlessSecurityNone(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "tls": false, } uri := ToProxyUri(node) if !strings.Contains(uri, "security=none") { t.Errorf("expected security=none: %s", uri) } } func TestExtraToProxyUriVlessSecurityTls(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "tls": true, "servername": "sni.com", } uri := ToProxyUri(node) if !strings.Contains(uri, "security=tls") { t.Errorf("expected security=tls: %s", uri) } if !strings.Contains(uri, "sni=sni.com") { t.Errorf("expected sni=sni.com: %s", uri) } } func TestExtraToProxyUriVlessRealitySpxFallback(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "tls": true, "reality-opts": map[string]any{ "public-key": "pk", "short-id": "sid", // spider-x missing → spx should fallback to "/" }, } uri := ToProxyUri(node) if !strings.Contains(uri, "spx=%2F") { // url-encoded "/" t.Errorf("expected spx=/ fallback: %s", uri) } } func TestExtraToProxyUriVlessFlow(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", "tls": true, "flow": "xtls-rprx-vision", } uri := ToProxyUri(node) if !strings.Contains(uri, "flow=xtls-rprx-vision") { t.Errorf("expected flow=xtls-rprx-vision: %s", uri) } } func TestExtraToProxyUriHysteria2Insecure(t *testing.T) { node := model.ProxyNode{ "type": "hysteria2", "name": "h", "server": "s", "port": 443, "password": "p", "skip-cert-verify": true, } uri := ToProxyUri(node) if !strings.Contains(uri, "insecure=1") { t.Errorf("expected insecure=1: %s", uri) } } func TestExtraToProxyUriHysteria2NoObfs(t *testing.T) { node := model.ProxyNode{ "type": "hysteria2", "name": "h", "server": "s", "port": 443, "password": "p", } uri := ToProxyUri(node) if strings.Contains(uri, "obfs=") { t.Errorf("expected no obfs param: %s", uri) } } func TestExtraToProxyUriTrojanAllowInsecure(t *testing.T) { node := model.ProxyNode{ "type": "trojan", "name": "t", "server": "s", "port": 443, "password": "p", "skip-cert-verify": true, } uri := ToProxyUri(node) if !strings.Contains(uri, "allowInsecure=1") { t.Errorf("expected allowInsecure=1: %s", uri) } } func TestExtraToProxyUriSocks5NoTls(t *testing.T) { node := model.ProxyNode{ "type": "socks5", "name": "s", "server": "s", "port": 1, "tls": false, } uri := ToProxyUri(node) if !strings.HasPrefix(uri, "socks5://") { t.Errorf("expected socks5:// (not socks5+tls): %s", uri) } } func TestExtraToProxyUriSocks5WithAuth(t *testing.T) { node := model.ProxyNode{ "type": "socks5", "name": "s", "server": "s", "port": 1, "username": "u", "password": "p", "tls": false, } uri := ToProxyUri(node) if !strings.Contains(uri, "u:p@") { t.Errorf("expected auth in uri: %s", uri) } } func TestExtraToProxyUriHTTPNoTls(t *testing.T) { node := model.ProxyNode{ "type": "http", "name": "h", "server": "s", "port": 80, "tls": false, } uri := ToProxyUri(node) if !strings.HasPrefix(uri, "http://") { t.Errorf("expected http://: %s", uri) } } func TestExtraToProxyUriHTTPWithAuth(t *testing.T) { node := model.ProxyNode{ "type": "http", "name": "h", "server": "s", "port": 80, "username": "u", "password": "p", } uri := ToProxyUri(node) if !strings.Contains(uri, "u:p@") { t.Errorf("expected auth in uri: %s", uri) } } func TestExtraToProxyUriSsBase64(t *testing.T) { node := model.ProxyNode{ "type": "ss", "name": "x", "server": "s", "port": 1, "cipher": "aes-256-gcm", "password": "p", } uri := ToProxyUri(node) // ss://base64(cipher:password@server:port)#name if !strings.HasPrefix(uri, "ss://") { t.Fatalf("expected ss:// prefix: %s", uri) } parts := strings.SplitN(uri, "#", 2) if len(parts) != 2 { t.Fatalf("expected fragment in uri: %s", uri) } b64 := strings.TrimPrefix(parts[0], "ss://") decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { t.Fatalf("ss userinfo not valid base64: %v", err) } if !strings.Contains(string(decoded), "aes-256-gcm:p@s:1") { t.Errorf("unexpected decoded ss userinfo: %s", decoded) } } func TestExtraToProxyUriSsr(t *testing.T) { node := nodeByName("SSR") uri := ToProxyUri(node) if !strings.HasPrefix(uri, "ssr://") { t.Fatalf("expected ssr:// prefix: %s", uri) } } func TestExtraToProxyUriVmess(t *testing.T) { node := nodeByName("VMess") uri := ToProxyUri(node) if !strings.HasPrefix(uri, "vmess://") { t.Fatalf("expected vmess:// prefix: %s", uri) } b64 := strings.TrimPrefix(uri, "vmess://") decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { t.Fatalf("vmess payload not valid base64: %v", err) } var parsed map[string]any if err := json.Unmarshal(decoded, &parsed); err != nil { t.Fatalf("vmess payload not valid JSON: %v", err) } if parsed["v"] != "2" { t.Errorf("expected v=2, got: %v", parsed["v"]) } if parsed["add"] != "vmess.example.com" { t.Errorf("expected add=vmess.example.com, got: %v", parsed["add"]) } if parsed["net"] != "ws" { t.Errorf("expected net=ws, got: %v", parsed["net"]) } } func TestExtraToProxyUriVmessNoPort(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "uuid": "u", } uri := ToProxyUri(node) b64 := strings.TrimPrefix(uri, "vmess://") decoded, _ := base64.StdEncoding.DecodeString(b64) var parsed map[string]any json.Unmarshal(decoded, &parsed) if parsed["port"] != "" { t.Errorf("expected empty port for 0, got: %v", parsed["port"]) } } func TestExtraRenderProxyUris(t *testing.T) { nodes := allNodeTypes() out := RenderProxyUris(nodes) lines := strings.Split(out, "\n") // 15 total nodes, but snell/ssh/h2-connect are not supported by URI renderer // so we expect 12 URIs wantLines := 12 if len(lines) != wantLines { t.Errorf("expected %d lines, got %d", wantLines, len(lines)) } } func TestExtraRenderProxyUrisWithUnsupported(t *testing.T) { nodes := []model.ProxyNode{ {"type": "snell", "name": "x"}, // unsupported {"type": "ss", "name": "y", "server": "s", "port": 1, "cipher": "c", "password": "p"}, } out := RenderProxyUris(nodes) if out == "" { t.Fatal("expected non-empty output") } if strings.Contains(out, "snell") { t.Errorf("snell should be filtered out: %s", out) } } func TestExtraSniStr(t *testing.T) { cases := []struct { proxy model.ProxyNode keys []string want string }{ {model.ProxyNode{"sni": "a"}, []string{"sni", "servername"}, "a"}, {model.ProxyNode{"servername": "b"}, []string{"sni", "servername"}, "b"}, {model.ProxyNode{}, []string{"sni", "servername"}, ""}, {model.ProxyNode{"sni": ""}, []string{"sni"}, ""}, } for _, c := range cases { got := sniStr(c.proxy, c.keys...) if got != c.want { t.Errorf("sniStr(%v, %v) = %q, want %q", c.proxy, c.keys, got, c.want) } } } func TestExtraAidStr(t *testing.T) { cases := []struct { in any want string }{ {nil, "0"}, {0, "0"}, {int64(64), "64"}, {float64(128), "128"}, {"", "0"}, {"256", "256"}, {true, "0"}, } for _, c := range cases { got := aidStr(c.in) if got != c.want { t.Errorf("aidStr(%v) = %q, want %q", c.in, got, c.want) } } } func TestExtraBoolToStr(t *testing.T) { if boolToStr(true, "yes", "no") != "yes" { t.Error("boolToStr(true) failed") } if boolToStr(false, "yes", "no") != "no" { t.Error("boolToStr(false) failed") } } func TestExtraWsHeaderHostKey(t *testing.T) { cases := []struct { name string wsOpts map[string]any want string }{ {"Host capital", map[string]any{"headers": map[string]any{"Host": "h.com"}}, "h.com"}, {"host lowercase", map[string]any{"headers": map[string]any{"host": "h.com"}}, "h.com"}, {"no headers", map[string]any{}, ""}, {"nil", nil, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := WsHeaderHostKey(c.wsOpts) if got != c.want { t.Errorf("WsHeaderHostKey(%v) = %q, want %q", c.wsOpts, got, c.want) } }) } } // --- textutil.go tests --- func TestExtraRenderTextProxyList(t *testing.T) { nodes := []model.ProxyNode{ {"type": "ss", "name": "a"}, {"type": "ss", "name": "b"}, } producer := func(p model.ProxyNode) string { return getString(p, "name") } out, err := RenderTextProxyList(nodes, "test", producer) if err != nil { t.Fatalf("unexpected error: %v", err) } if out != "a\nb" { t.Errorf("expected 'a\\nb', got: %q", out) } } func TestExtraRenderTextProxyListSkipEmpty(t *testing.T) { nodes := []model.ProxyNode{ {"type": "ss", "name": "a"}, {"type": "ss", "name": "skip"}, // producer returns "" {"type": "ss", "name": "b"}, } producer := func(p model.ProxyNode) string { if getString(p, "name") == "skip" { return "" } return getString(p, "name") } out, err := RenderTextProxyList(nodes, "test", producer) if err != nil { t.Fatalf("unexpected error: %v", err) } if out != "a\nb" { t.Errorf("expected 'a\\nb', got: %q", out) } } func TestExtraRenderTextProxyListAllEmpty(t *testing.T) { nodes := []model.ProxyNode{ {"type": "ss", "name": "a"}, {"type": "ss", "name": "b"}, } producer := func(p model.ProxyNode) string { return "" } _, err := RenderTextProxyList(nodes, "test", producer) if err == nil { t.Fatal("expected error when all producers return empty") } } func TestExtraJoinTextProxy(t *testing.T) { entries := [][2]any{ {"a", "1"}, {"b", "2"}, {"c", nil}, // should be dropped {"d", ""}, // should be dropped {"e", true}, {"f", false}, } got := JoinTextProxy("base", entries) want := "base,a=1,b=2,e=true,f=false" if got != want { t.Errorf("JoinTextProxy = %q, want %q", got, want) } } func TestExtraJoinTextProxyNoEntries(t *testing.T) { got := JoinTextProxy("base", nil) if got != "base" { t.Errorf("expected base only, got: %q", got) } } func TestExtraJoinTextProxyAllNilEntries(t *testing.T) { entries := [][2]any{ {"a", nil}, {"b", ""}, } got := JoinTextProxy("base", entries) if got != "base" { t.Errorf("expected base only when all entries nil/empty, got: %q", got) } } func TestExtraFormatTextOptionValue(t *testing.T) { cases := []struct { name string input any want string }{ {"bool_true", true, "true"}, {"bool_false", false, "false"}, {"nil", nil, ""}, {"int", 42, "42"}, {"string_plain", "hello", "hello"}, {"string_with_comma", "a,b", `"a,b"`}, {"string_with_space", "a b", `"a b"`}, {"string_with_quote", `a"b`, `"a\"b"`}, {"array_any", []any{1, 2, 3}, `"1,2,3"`}, {"array_string", []string{"a", "b"}, `"a,b"`}, {"array_empty", []any{}, `""`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := FormatTextOptionValue(c.input) if got != c.want { t.Errorf("FormatTextOptionValue(%v) = %q, want %q", c.input, got, c.want) } }) } } func TestExtraQuoteTextValue(t *testing.T) { cases := []struct { in any want string }{ {nil, `""`}, {"hello", `"hello"`}, {`a"b`, `"a\"b"`}, {42, `"42"`}, {"", `""`}, } for _, c := range cases { got := QuoteTextValue(c.in) if got != c.want { t.Errorf("QuoteTextValue(%v) = %q, want %q", c.in, got, c.want) } } } func TestExtraSanitizeTextProxyName(t *testing.T) { cases := []struct { in string want string }{ {"name", "name"}, {"name=test", "name test"}, {"name,with,comma", "name with comma"}, {"name\r\n", "name"}, {"", "proxy"}, {" ", "proxy"}, {"=,\r\n", "proxy"}, {" name ", "name"}, } for _, c := range cases { got := SanitizeTextProxyName(c.in) if got != c.want { t.Errorf("SanitizeTextProxyName(%q) = %q, want %q", c.in, got, c.want) } } } func TestExtraCommonTextOptions(t *testing.T) { proxy := model.ProxyNode{ "skip-cert-verify": true, "udp": true, "tfo": true, "alpn": []any{"h2", "http/1.1"}, } entries := CommonTextOptions(proxy) m := make(map[string]any) for _, e := range entries { m[e[0].(string)] = e[1] } if m["skip-cert-verify"] != true { t.Error("expected skip-cert-verify=true") } if m["udp-relay"] != true { t.Error("expected udp-relay=true") } if m["fast-open"] != true { t.Error("expected fast-open=true (from tfo)") } if m["alpn"] != "h2,http/1.1" { t.Errorf("expected alpn=h2,http/1.1, got: %v", m["alpn"]) } } func TestExtraCommonTextOptionsFastOpenFallback(t *testing.T) { proxy := model.ProxyNode{ "fast-open": true, } entries := CommonTextOptions(proxy) m := make(map[string]any) for _, e := range entries { m[e[0].(string)] = e[1] } if m["fast-open"] != true { t.Error("expected fast-open=true from fallback") } } func TestExtraFastOpenValue(t *testing.T) { // tfo takes priority over fast-open if v := fastOpenValue(model.ProxyNode{"tfo": "x", "fast-open": "y"}); v != "x" { t.Errorf("expected tfo=x to win, got: %v", v) } // falls back to fast-open if v := fastOpenValue(model.ProxyNode{"fast-open": "y"}); v != "y" { t.Errorf("expected fast-open=y, got: %v", v) } // both nil if v := fastOpenValue(model.ProxyNode{}); v != nil { t.Errorf("expected nil, got: %v", v) } } func TestExtraAppendWsOptionsSurge(t *testing.T) { proxy := model.ProxyNode{ "network": "ws", "ws-opts": map[string]any{ "path": "/ws", "headers": map[string]any{"Host": "h.com"}, }, } entries := AppendWsOptions([][2]any{}, proxy, "surge") m := entriesToMap(entries) if m["ws"] != true { t.Error("expected ws=true") } if m["ws-path"] != "/ws" { t.Errorf("expected ws-path=/ws, got: %v", m["ws-path"]) } if m["ws-headers"] != "h.com" { t.Errorf("expected ws-headers=h.com, got: %v", m["ws-headers"]) } } func TestExtraAppendWsOptionsNonSurge(t *testing.T) { proxy := model.ProxyNode{ "network": "ws", "ws-opts": map[string]any{"path": "/ws", "headers": map[string]any{"Host": "h"}}, } entries := AppendWsOptions([][2]any{}, proxy, "loon") m := entriesToMap(entries) if _, ok := m["ws"]; ok { t.Error("expected no ws=true for non-surge") } if m["path"] != "/ws" { t.Errorf("expected path=/ws, got: %v", m["path"]) } if m["host"] != "h" { t.Errorf("expected host=h, got: %v", m["host"]) } } func TestExtraAppendWsOptionsNonWs(t *testing.T) { proxy := model.ProxyNode{"network": "tcp"} entries := AppendWsOptions([][2]any{{"x", "y"}}, proxy, "surge") if len(entries) != 1 { t.Errorf("expected unchanged entries for non-ws, got: %v", entries) } } func TestExtraAppendWsOptionsDefaultPath(t *testing.T) { proxy := model.ProxyNode{"network": "ws"} entries := AppendWsOptions([][2]any{}, proxy, "surge") m := entriesToMap(entries) if m["ws-path"] != "/" { t.Errorf("expected default ws-path=/, got: %v", m["ws-path"]) } } func TestExtraAppendPluginOptionsSurge(t *testing.T) { proxy := model.ProxyNode{ "plugin": "obfs", "plugin-opts": map[string]any{ "mode": "http", "host": "h.com", "path": "/p", }, } entries := AppendPluginOptions([][2]any{}, proxy, "surge") m := entriesToMap(entries) if m["obfs"] != "http" { t.Errorf("expected obfs=http, got: %v", m["obfs"]) } if m["obfs-host"] != "h.com" { t.Errorf("expected obfs-host=h.com, got: %v", m["obfs-host"]) } if m["obfs-uri"] != "/p" { t.Errorf("expected obfs-uri=/p, got: %v", m["obfs-uri"]) } } func TestExtraAppendPluginOptionsNonSurge(t *testing.T) { proxy := model.ProxyNode{ "plugin": "obfs", "plugin-opts": map[string]any{ "mode": "http", "host": "h.com", "path": "/p", }, } entries := AppendPluginOptions([][2]any{}, proxy, "loon") m := entriesToMap(entries) if m["obfs-name"] != "http" { t.Errorf("expected obfs-name=http, got: %v", m["obfs-name"]) } } func TestExtraAppendPluginOptionsNonObfs(t *testing.T) { proxy := model.ProxyNode{"plugin": "other"} entries := AppendPluginOptions([][2]any{{"x", "y"}}, proxy, "surge") if len(entries) != 1 { t.Errorf("expected unchanged entries for non-obfs, got: %v", entries) } } func TestExtraAppendPluginOptionsNoOpts(t *testing.T) { proxy := model.ProxyNode{"plugin": "obfs"} entries := AppendPluginOptions([][2]any{{"x", "y"}}, proxy, "surge") if len(entries) != 1 { t.Errorf("expected unchanged entries when no plugin-opts, got: %v", entries) } } func TestExtraAppendRealityOptions(t *testing.T) { proxy := model.ProxyNode{ "reality-opts": map[string]any{ "public-key": "pk", "short-id": "sid", }, } entries := AppendRealityOptions([][2]any{}, proxy) m := entriesToMap(entries) if m["public-key"] != "pk" { t.Errorf("expected public-key=pk, got: %v", m["public-key"]) } if m["short-id"] != "sid" { t.Errorf("expected short-id=sid, got: %v", m["short-id"]) } } func TestExtraAppendRealityOptionsNoOpts(t *testing.T) { proxy := model.ProxyNode{} entries := AppendRealityOptions([][2]any{}, proxy) // AppendRealityOptions always appends entries, even when reality-opts is nil. // The nil values would be dropped by JoinTextProxy, but the entries exist. if len(entries) != 2 { t.Fatalf("expected 2 entries (public-key, short-id), got %d", len(entries)) } if entries[0][0] != "public-key" || entries[0][1] != nil { t.Errorf("expected public-key=nil, got: %v", entries[0]) } if entries[1][0] != "short-id" || entries[1][1] != nil { t.Errorf("expected short-id=nil, got: %v", entries[1]) } } func TestExtraAppendQxRealityOptions(t *testing.T) { proxy := model.ProxyNode{ "reality-opts": map[string]any{ "public-key": "pk", "short-id": "sid", }, } entries := AppendQxRealityOptions([][2]any{}, proxy) m := entriesToMap(entries) if m["reality-base64-pubkey"] != "pk" { t.Errorf("expected reality-base64-pubkey=pk, got: %v", m["reality-base64-pubkey"]) } if m["reality-hex-shortid"] != "sid" { t.Errorf("expected reality-hex-shortid=sid, got: %v", m["reality-hex-shortid"]) } } func TestExtraAppendQxObfs(t *testing.T) { proxy := model.ProxyNode{ "plugin": "obfs", "plugin-opts": map[string]any{ "mode": "http", "host": "h.com", "path": "/p", }, } entries := AppendQxObfs([][2]any{}, proxy) m := entriesToMap(entries) if m["obfs"] != "http" { t.Errorf("expected obfs=http, got: %v", m["obfs"]) } } func TestExtraAppendQxObfsNonObfs(t *testing.T) { proxy := model.ProxyNode{"plugin": "other"} entries := AppendQxObfs([][2]any{{"x", "y"}}, proxy) if len(entries) != 1 { t.Errorf("expected unchanged for non-obfs, got: %v", entries) } } func TestExtraAppendQxObfsNoOpts(t *testing.T) { proxy := model.ProxyNode{"plugin": "obfs"} entries := AppendQxObfs([][2]any{{"x", "y"}}, proxy) if len(entries) != 1 { t.Errorf("expected unchanged when no plugin-opts, got: %v", entries) } } func TestExtraAppendQxTransport(t *testing.T) { proxy := model.ProxyNode{ "network": "ws", "tls": true, "ws-opts": map[string]any{ "path": "/ws", "headers": map[string]any{"Host": "h.com"}, }, } entries := AppendQxTransport([][2]any{}, proxy) m := entriesToMap(entries) if m["obfs"] != "wss" { t.Errorf("expected obfs=wss, got: %v", m["obfs"]) } if m["obfs-uri"] != "/ws" { t.Errorf("expected obfs-uri=/ws, got: %v", m["obfs-uri"]) } } func TestExtraAppendQxTransportNonWs(t *testing.T) { proxy := model.ProxyNode{"network": "tcp"} entries := AppendQxTransport([][2]any{{"x", "y"}}, proxy) if len(entries) != 1 { t.Errorf("expected unchanged for non-ws, got: %v", entries) } } func TestExtraWsHeaderHost(t *testing.T) { cases := []struct { name string wsOpts map[string]any want string }{ {"Host capital", map[string]any{"headers": map[string]any{"Host": "h.com"}}, "h.com"}, {"host lowercase", map[string]any{"headers": map[string]any{"host": "h.com"}}, "h.com"}, {"no headers", map[string]any{}, ""}, {"nil", nil, ""}, {"headers not map", map[string]any{"headers": "not-a-map"}, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := WsHeaderHost(c.wsOpts) if got != c.want { t.Errorf("WsHeaderHost(%v) = %q, want %q", c.wsOpts, got, c.want) } }) } } func TestExtraFormatAlpn(t *testing.T) { cases := []struct { name string input any want string }{ {"nil", nil, ""}, {"array_any", []any{"h2", "http/1.1"}, "h2,http/1.1"}, {"array_any_with_empty", []any{"h2", "", "http/1.1"}, "h2,http/1.1"}, {"array_string", []string{"h2", "http/1.1"}, "h2,http/1.1"}, {"array_string_with_empty", []string{"h2", "", "http/1.1"}, "h2,http/1.1"}, {"string", "h2", "h2"}, {"int", 42, "42"}, {"empty_array", []any{}, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := FormatAlpn(c.input) if got != c.want { t.Errorf("FormatAlpn(%v) = %q, want %q", c.input, got, c.want) } }) } } func TestExtraGetString(t *testing.T) { proxy := model.ProxyNode{"key": "value", "num": 42} if getString(proxy, "key") != "value" { t.Error("getString failed for string") } if getString(proxy, "num") != "" { t.Error("getString should return empty for non-string") } if getString(proxy, "missing") != "" { t.Error("getString should return empty for missing key") } } func TestExtraGetBool(t *testing.T) { cases := []struct { input any want bool }{ {true, true}, {false, false}, {"true", true}, {"1", true}, {"false", false}, {"0", false}, {42, false}, {nil, false}, } for _, c := range cases { proxy := model.ProxyNode{"key": c.input} got := getBool(proxy, "key") if got != c.want { t.Errorf("getBool(%v) = %v, want %v", c.input, got, c.want) } } } func TestExtraGetInt(t *testing.T) { cases := []struct { input any want int }{ {42, 42}, {int64(64), 64}, {float64(128.0), 128}, {"256", 256}, {"not-a-num", 0}, {nil, 0}, {true, 0}, } for _, c := range cases { proxy := model.ProxyNode{"key": c.input} got := getInt(proxy, "key") if got != c.want { t.Errorf("getInt(%v) = %d, want %d", c.input, got, c.want) } } } func TestExtraStringSetting(t *testing.T) { if stringSetting(nil) != "" { t.Error("expected empty for nil") } if stringSetting("hello") != "hello" { t.Error("expected hello") } if stringSetting(42) != "42" { t.Error("expected 42") } } func TestExtraNumberOrUndefined(t *testing.T) { cases := []struct { input any want int }{ {nil, 0}, {42, 42}, {int64(64), 64}, {float64(128.0), 128}, {"", 0}, {"256", 256}, {"not-a-num", 0}, {true, 0}, } for _, c := range cases { got := numberOrUndefined(c.input) if got != c.want { t.Errorf("numberOrUndefined(%v) = %d, want %d", c.input, got, c.want) } } } func TestExtraHasValue(t *testing.T) { cases := []struct { input any want bool }{ {nil, false}, {"", false}, {"hello", true}, {42, true}, {true, true}, {false, true}, // false is not nil or empty-string } for _, c := range cases { got := hasValue(c.input) if got != c.want { t.Errorf("hasValue(%v) = %v, want %v", c.input, got, c.want) } } } // --- surge.go helpers --- func TestExtraItoa(t *testing.T) { cases := []struct { in int want string }{ {0, "0"}, {42, "42"}, {-42, "-42"}, {123456, "123456"}, } for _, c := range cases { if got := itoa(c.in); got != c.want { t.Errorf("itoa(%d) = %q, want %q", c.in, got, c.want) } } } func TestExtraUnshift(t *testing.T) { base := [][2]any{{"a", 1}, {"b", 2}} front := [][2]any{{"x", 9}, {"y", 8}} got := unshift(base, front...) if len(got) != 4 { t.Fatalf("expected 4 entries, got %d", len(got)) } if got[0][0] != "x" || got[1][0] != "y" || got[2][0] != "a" || got[3][0] != "b" { t.Errorf("unshift order wrong: %v", got) } } func TestExtraStrOr(t *testing.T) { proxy := model.ProxyNode{"cipher": "aes-256-gcm"} if strOr(proxy, "cipher", "default") != "aes-256-gcm" { t.Error("expected cipher value") } if strOr(proxy, "missing", "default") != "default" { t.Error("expected default for missing key") } proxy2 := model.ProxyNode{"cipher": ""} if strOr(proxy2, "cipher", "default") != "default" { t.Error("expected default for empty string") } } func TestExtraSniOr(t *testing.T) { proxy := model.ProxyNode{"sni": "a", "servername": "b"} if sniOr(proxy, "sni", "servername") != "a" { t.Error("expected first key") } proxy2 := model.ProxyNode{"servername": "b"} if sniOr(proxy2, "sni", "servername") != "b" { t.Error("expected second key") } if sniOr(model.ProxyNode{}, "sni", "servername") != nil { t.Error("expected nil when none present") } } func TestExtraPskOrPassword(t *testing.T) { proxy := model.ProxyNode{"psk": "psk-val"} if pskOrPassword(proxy) != "psk-val" { t.Error("expected psk value") } proxy2 := model.ProxyNode{"password": "pass-val"} if pskOrPassword(proxy2) != "pass-val" { t.Error("expected password value as fallback") } if pskOrPassword(model.ProxyNode{}) != nil { t.Error("expected nil when neither present") } } func TestExtraVersionOr3(t *testing.T) { proxy := model.ProxyNode{"version": 4} if versionOr3(proxy) != 4 { t.Error("expected version 4") } if versionOr3(model.ProxyNode{}) != 3 { t.Error("expected default 3") } } func TestExtraSurgeType(t *testing.T) { cases := []struct { proxy model.ProxyNode want string }{ {model.ProxyNode{"type": "socks5", "tls": true}, "socks5-tls"}, {model.ProxyNode{"type": "socks5", "tls": false}, "socks5"}, {model.ProxyNode{"type": "http", "tls": true}, "https"}, {model.ProxyNode{"type": "http", "tls": false}, "http"}, {model.ProxyNode{"type": "vmess"}, "vmess"}, {model.ProxyNode{"type": "trojan"}, "trojan"}, } for _, c := range cases { got := surgeType(c.proxy) if got != c.want { t.Errorf("surgeType(%v) = %q, want %q", c.proxy, got, c.want) } } } // --- mihomo.go tests --- func TestExtraRenderMihomoYamlDefault(t *testing.T) { nodes := allNodeTypes()[:2] out := RenderMihomoYaml(nodes, "https://sub.example.com/api", nil) var parsed map[string]any if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("not valid YAML: %v\n%s", err, out) } if parsed["mixed-port"] != 7890 { t.Errorf("expected default mixed-port=7890, got: %v", parsed["mixed-port"]) } if parsed["mode"] != "rule" { t.Errorf("expected default mode=rule, got: %v", parsed["mode"]) } if parsed["log-level"] != "info" { t.Errorf("expected default log-level=info, got: %v", parsed["log-level"]) } if parsed["allow-lan"] != false { t.Errorf("expected default allow-lan=false, got: %v", parsed["allow-lan"]) } // Note: DefaultProxyGroups uses []string for "proxies" but ExpandGroupProxies // expects []any, so $all is never expanded and default groups produce empty // proxy lists. This is a known source-code limitation; we verify the document // still has the proxy-groups key present. if _, ok := parsed["proxy-groups"]; !ok { t.Error("expected proxy-groups key in output") } rules, _ := parsed["rules"].([]any) if len(rules) != 1 || rules[0] != "MATCH,🚀 节点选择" { t.Errorf("expected default MATCH rule, got: %v", rules) } } func TestExtraRenderMihomoYamlWithTemplate(t *testing.T) { nodes := allNodeTypes()[:3] tmpl := map[string]any{ "mixed-port": 7891, "allow-lan": true, "mode": "global", "log-level": "debug", "dns": map[string]any{"enable": true}, "sniffer": map[string]any{"enable": true}, "rules": []any{ "GEOIP,CN,DIRECT", "MATCH,PROXY", }, } out := RenderMihomoYaml(nodes, "https://sub.example.com/api", tmpl) var parsed map[string]any if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("not valid YAML: %v\n%s", err, out) } if parsed["mixed-port"] != 7891 { t.Errorf("expected mixed-port=7891, got: %v", parsed["mixed-port"]) } if parsed["allow-lan"] != true { t.Errorf("expected allow-lan=true, got: %v", parsed["allow-lan"]) } if parsed["mode"] != "global" { t.Errorf("expected mode=global, got: %v", parsed["mode"]) } if parsed["log-level"] != "debug" { t.Errorf("expected log-level=debug, got: %v", parsed["log-level"]) } if _, ok := parsed["dns"]; !ok { t.Error("expected dns in output") } if _, ok := parsed["sniffer"]; !ok { t.Error("expected sniffer in output") } rules, _ := parsed["rules"].([]any) if len(rules) != 2 { t.Errorf("expected 2 rules, got %d", len(rules)) } } func TestExtraRenderMihomoYamlRuleProviders(t *testing.T) { nodes := allNodeTypes()[:1] tmpl := map[string]any{ "rule-providers": map[string]any{ "reject": map[string]any{"type": "http", "url": "x"}, }, } out := RenderMihomoYaml(nodes, "https://x", tmpl) var parsed map[string]any if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("not valid YAML: %v", err) } if _, ok := parsed["rule-providers"]; !ok { t.Error("expected rule-providers in output") } } func TestExtraRenderMihomoYamlCamelCaseKeys(t *testing.T) { // Test camelCase variants (mixedPort, allowLan, logLevel) tmpl := map[string]any{ "mixedPort": 7892, "allowLan": true, "logLevel": "warning", } nodes := allNodeTypes()[:1] out := RenderMihomoYaml(nodes, "https://x", tmpl) var parsed map[string]any if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("not valid YAML: %v", err) } if parsed["mixed-port"] != 7892 { t.Errorf("expected mixed-port=7892 from mixedPort, got: %v", parsed["mixed-port"]) } if parsed["allow-lan"] != true { t.Errorf("expected allow-lan=true from allowLan, got: %v", parsed["allow-lan"]) } if parsed["log-level"] != "warning" { t.Errorf("expected log-level=warning from logLevel, got: %v", parsed["log-level"]) } } func TestExtraRenderMihomoYamlCustomProxyGroups(t *testing.T) { nodes := allNodeTypes()[:3] tmpl := map[string]any{ "proxy-groups": []any{ map[string]any{ "name": "PROXY", "type": "select", "proxies": []any{"SS-Node", "VMess-Node", "DIRECT"}, }, map[string]any{ "name": "AUTO", "type": "url-test", "filter": ".*", "url": "http://test", }, }, } out := RenderMihomoYaml(nodes, "https://x", tmpl) var parsed map[string]any if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("not valid YAML: %v\n%s", err, out) } groups, _ := parsed["proxy-groups"].([]any) if len(groups) != 2 { t.Fatalf("expected 2 groups, got %d", len(groups)) } first, _ := groups[0].(map[string]any) if first["name"] != "PROXY" { t.Errorf("expected first group PROXY, got: %v", first["name"]) } } func TestExtraRenderMihomoYamlHeaderComment(t *testing.T) { out := RenderMihomoYaml(allNodeTypes()[:1], "https://sub.example.com/api/path", nil) if !strings.HasPrefix(out, "# Generated by Sub-Store") { t.Errorf("expected header comment, got: %s", out[:50]) } if !strings.Contains(out, "# Source: /api/path") { t.Errorf("expected source path comment, got: %s", out) } } func TestExtraDefaultProxyGroups(t *testing.T) { groups := DefaultProxyGroups() if len(groups) != 3 { t.Fatalf("expected 3 default groups, got %d", len(groups)) } if groups[0]["name"] != "🚀 节点选择" { t.Errorf("expected first group 🚀 节点选择, got: %v", groups[0]["name"]) } if groups[1]["type"] != "url-test" { t.Errorf("expected second group url-test, got: %v", groups[1]["type"]) } } // --- mihomo: ExpandGroupProxies & RenderTemplateProxyGroups --- func TestExtraExpandGroupProxiesAll(t *testing.T) { group := map[string]any{ "proxies": []any{"$all"}, } nodeNames := []string{"A", "B", "C"} got := ExpandGroupProxies(group, nodeNames) if !reflect.DeepEqual(got, nodeNames) { t.Errorf("ExpandGroupProxies $all = %v, want %v", got, nodeNames) } } func TestExtraExpandGroupProxiesExplicitNames(t *testing.T) { group := map[string]any{ "proxies": []any{"X", "Y", "$all", "Z"}, } nodeNames := []string{"A", "B"} got := ExpandGroupProxies(group, nodeNames) want := []string{"X", "Y", "A", "B", "Z"} if !reflect.DeepEqual(got, want) { t.Errorf("ExpandGroupProxies = %v, want %v", got, want) } } func TestExtraExpandGroupProxiesFilter(t *testing.T) { group := map[string]any{ "filter": "HK.*", } nodeNames := []string{"HK-1", "US-1", "HK-2", "JP-1"} got := ExpandGroupProxies(group, nodeNames) want := []string{"HK-1", "HK-2"} if !reflect.DeepEqual(got, want) { t.Errorf("ExpandGroupProxies filter = %v, want %v", got, want) } } func TestExtraExpandGroupProxiesFilterCaseInsensitive(t *testing.T) { // Note: compileRegex strips "(?i)" prefix and prepends "i" as a flag, // but Go regexp expects "(?i)" syntax not a bare flag prefix. // So "(?i)hk.*" compiles to "ihk.*" which matches nothing. // This test verifies the actual (buggy) behavior. group := map[string]any{ "filter": "(?i)hk.*", } nodeNames := []string{"hk-1", "HK-2", "us-1"} got := ExpandGroupProxies(group, nodeNames) // Due to the bug, no names match if len(got) != 0 { t.Errorf("expected 0 matches due to (?i) handling bug, got: %v", got) } } func TestExtraExpandGroupProxiesFilterInvalid(t *testing.T) { group := map[string]any{ "filter": "[invalid", } got := ExpandGroupProxies(group, []string{"A"}) if len(got) != 0 { t.Errorf("expected empty for invalid regex, got: %v", got) } } func TestExtraExpandGroupProxiesFilterAndProxies(t *testing.T) { group := map[string]any{ "filter": "HK.*", "proxies": []any{"DIRECT", "$all"}, } nodeNames := []string{"HK-1", "US-1"} got := ExpandGroupProxies(group, nodeNames) // filter results first, then proxies want := []string{"HK-1", "DIRECT", "HK-1", "US-1"} // uniqueStrings will dedupe if !reflect.DeepEqual(got, want) { // Due to uniqueStrings, HK-1 appears once want = []string{"HK-1", "DIRECT", "US-1"} if !reflect.DeepEqual(got, want) { t.Errorf("ExpandGroupProxies filter+proxies = %v, want %v", got, want) } } } func TestExtraExpandGroupProxiesEmptyStrings(t *testing.T) { group := map[string]any{ "proxies": []any{"", "A", "", "B"}, } got := ExpandGroupProxies(group, []string{}) want := []string{"A", "B"} if !reflect.DeepEqual(got, want) { t.Errorf("ExpandGroupProxies empty strings = %v, want %v", got, want) } } func TestExtraRenderTemplateProxyGroupsFiltersEmpty(t *testing.T) { proxies := allNodeTypes()[:2] groups := []map[string]any{ { "name": "Empty", "type": "select", "proxies": []any{"NONEXISTENT"}, }, { "name": "Real", "type": "select", "proxies": []any{"SS-Node", "VMess-Node"}, }, } result := RenderTemplateProxyGroups(proxies, groups) if len(result) != 1 { t.Fatalf("expected 1 group (empty filtered), got %d", len(result)) } if result[0]["name"] != "Real" { t.Errorf("expected Real group, got: %v", result[0]["name"]) } } func TestExtraRenderTemplateProxyGroupsDeduplicates(t *testing.T) { proxies := allNodeTypes()[:2] groups := []map[string]any{ { "name": "Test", "type": "select", "proxies": []any{"SS-Node", "SS-Node", "VMess-Node"}, }, } result := RenderTemplateProxyGroups(proxies, groups) if len(result) != 1 { t.Fatalf("expected 1 group, got %d", len(result)) } proxiesList, _ := result[0]["proxies"].([]string) if len(proxiesList) != 2 { t.Errorf("expected 2 unique proxies, got %d: %v", len(proxiesList), proxiesList) } } func TestExtraRenderTemplateProxyGroupsKeepsAllowedLiterals(t *testing.T) { proxies := allNodeTypes()[:1] groups := []map[string]any{ { "name": "Test", "type": "select", "proxies": []any{"SS-Node", "DIRECT", "REJECT", "PASS"}, }, } result := RenderTemplateProxyGroups(proxies, groups) proxiesList, _ := result[0]["proxies"].([]string) if len(proxiesList) != 4 { t.Errorf("expected 4 entries (node+3 literals), got %d: %v", len(proxiesList), proxiesList) } } func TestExtraRenderTemplateProxyGroupsStripsFilterAndEmpty(t *testing.T) { proxies := allNodeTypes()[:2] groups := []map[string]any{ { "name": "Test", "type": "select", "filter": ".*", "proxies": []any{"SS-Node"}, "extra": "", "url": "http://test", }, } result := RenderTemplateProxyGroups(proxies, groups) if len(result) != 1 { t.Fatalf("expected 1 group, got %d", len(result)) } if _, ok := result[0]["filter"]; ok { t.Error("filter should be stripped from output") } if _, ok := result[0]["extra"]; ok { t.Error("empty string extra should be stripped") } if result[0]["url"] != "http://test" { t.Error("url should be preserved") } } func TestExtraRenderTemplateProxyGroupsCrossGroupRef(t *testing.T) { proxies := allNodeTypes()[:1] groups := []map[string]any{ { "name": "Main", "type": "select", "proxies": []any{"SS-Node", "Sub"}, }, { "name": "Sub", "type": "select", "proxies": []any{"SS-Node"}, }, } result := RenderTemplateProxyGroups(proxies, groups) if len(result) != 2 { t.Fatalf("expected 2 groups, got %d", len(result)) } } // --- mihomo helpers --- func TestExtraExtractGroupTemplates(t *testing.T) { cases := []struct { name string cfg map[string]any want int }{ {"proxyGroups camelCase", map[string]any{"proxyGroups": []any{map[string]any{"name": "x"}}}, 1}, {"proxy-groups kebab", map[string]any{"proxy-groups": []any{map[string]any{"name": "x"}}}, 1}, {"none", map[string]any{}, 0}, {"invalid type", map[string]any{"proxyGroups": "not-an-array"}, 0}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := extractGroupTemplates(c.cfg) if len(got) != c.want { t.Errorf("extractGroupTemplates = %d groups, want %d", len(got), c.want) } }) } } func TestExtraFirstStr(t *testing.T) { cfg := map[string]any{"a": "x", "b": ""} if firstStr(cfg, "a", "b") != "x" { t.Error("expected x") } if firstStr(cfg, "b", "c") != "" { t.Error("expected empty") } if firstStr(cfg, "missing") != "" { t.Error("expected empty for missing") } } func TestExtraFirstInt(t *testing.T) { cfg := map[string]any{"a": 42, "b": int64(64), "c": float64(128)} if firstInt(cfg, "a") != 42 { t.Error("expected 42") } if firstInt(cfg, "b") != 64 { t.Error("expected 64") } if firstInt(cfg, "c") != 128 { t.Error("expected 128") } if firstInt(cfg, "missing") != 0 { t.Error("expected 0 for missing") } } func TestExtraFirstBool(t *testing.T) { cfg := map[string]any{"a": true, "b": false} if !firstBool(cfg, "a") { t.Error("expected true") } if firstBool(cfg, "b") { t.Error("expected false") } if firstBool(cfg, "missing") { t.Error("expected false for missing") } if firstBool(cfg, "x", "y") { t.Error("expected false for all missing") } } func TestExtraFirstAny(t *testing.T) { cfg := map[string]any{"a": "value"} if firstAny(cfg, "a") != "value" { t.Error("expected value") } if firstAny(cfg, "missing") != nil { t.Error("expected nil for missing") } if firstAny(cfg, "nilkey") != nil { t.Error("expected nil") } } func TestExtraContainsString(t *testing.T) { list := []string{"a", "b", "c"} if !containsString(list, "b") { t.Error("expected true for b") } if containsString(list, "z") { t.Error("expected false for z") } if containsString([]string{}, "a") { t.Error("expected false for empty list") } } func TestExtraUniqueStrings(t *testing.T) { input := []string{"a", "b", "a", "", "c", "b"} got := uniqueStrings(input) want := []string{"a", "b", "c"} if !reflect.DeepEqual(got, want) { t.Errorf("uniqueStrings = %v, want %v", got, want) } } func TestExtraFindNamesByRegex(t *testing.T) { names := []string{"HK-1", "US-1", "HK-2"} got := findNamesByRegex(names, "HK.*") want := []string{"HK-1", "HK-2"} if !reflect.DeepEqual(got, want) { t.Errorf("findNamesByRegex = %v, want %v", got, want) } } func TestExtraFindNamesByRegexInvalid(t *testing.T) { got := findNamesByRegex([]string{"a"}, "[invalid") if got != nil { t.Errorf("expected nil for invalid regex, got: %v", got) } } func TestExtraCompileRegex(t *testing.T) { cases := []struct { input string nil bool }{ {"abc", false}, {"(?i)abc", false}, {"[invalid", true}, } for _, c := range cases { re := compileRegex(c.input) if c.nil && re != nil { t.Errorf("expected nil for %q", c.input) } if !c.nil && re == nil { t.Errorf("expected non-nil for %q", c.input) } } } func TestExtraCompileRegexCaseInsensitive(t *testing.T) { re := compileRegex("(?i)abc") if re == nil { t.Fatal("expected non-nil regex") } // Note: compileRegex strips "(?i)" and prepends "i" as a bare flag, // which Go regexp interprets as part of the pattern, not as a flag. // So "(?i)abc" becomes "iabc" which does NOT match "ABC". // This test verifies the actual (buggy) behavior. if re.MatchString("iabc") { // "iabc" matches because the pattern is literally "iabc" } else { t.Error("expected pattern 'iabc' to match string 'iabc'") } } // --- json.go tests --- func TestExtraRenderJsonStripsNil(t *testing.T) { nodes := []model.ProxyNode{ {"type": "ss", "name": "x", "server": nil, "port": 1}, } out := RenderJson(nodes) var parsed map[string]any if err := json.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("invalid JSON: %v", err) } proxies, _ := parsed["proxies"].([]any) if len(proxies) != 1 { t.Fatalf("expected 1 proxy, got %d", len(proxies)) } first, _ := proxies[0].(map[string]any) if _, ok := first["server"]; ok { t.Error("nil server should be stripped") } if first["type"] != "ss" { t.Error("type should be preserved") } } func TestExtraStripNil(t *testing.T) { m := map[string]any{"a": 1, "b": nil, "c": "x"} result := stripNil(m) if _, ok := result["b"]; ok { t.Error("nil should be removed") } if result["a"] != 1 || result["c"] != "x" { t.Error("non-nil should be preserved") } } // --- v2ray base64 output --- func TestExtraRenderV2rayBase64(t *testing.T) { nodes := allNodeTypes()[:2] out, err := RenderTarget(nodes, "v2ray", nil) if err != nil { t.Fatalf("v2ray render failed: %v", err) } // v2ray output is base64-encoded URI list decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(out)) if err != nil { // Some encoders use raw URL encoding; try that decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimSpace(out)) if err != nil { // It may also use RawStdEncoding decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(out)) if err != nil { t.Fatalf("v2ray output is not valid base64: %v\noutput: %q", err, out) } } } if !strings.Contains(string(decoded), "://") { t.Errorf("decoded v2ray output should contain URI scheme: %s", decoded) } } // --- shadowrocket alias --- func TestExtraShadowrocketAliasesToURI(t *testing.T) { nodes := allNodeTypes()[:2] outURI, err := RenderTarget(nodes, "uri", nil) if err != nil { t.Fatalf("uri render failed: %v", err) } outSR, err := RenderTarget(nodes, "shadowrocket", nil) if err != nil { t.Fatalf("shadowrocket render failed: %v", err) } if outURI != outSR { t.Errorf("shadowrocket should alias to uri; uri=%q sr=%q", outURI, outSR) } } // --- stash target (delegates to mihomo) --- func TestExtraStashDelegatesToMihomo(t *testing.T) { nodes := allNodeTypes()[:1] outStash, err := RenderTarget(nodes, "stash", nil) if err != nil { t.Fatalf("stash render failed: %v", err) } if !strings.Contains(outStash, "Generated by Sub-Store") { t.Errorf("stash should produce mihomo-style YAML: %s", outStash) } } // --- additional coverage: RenderProxyUris via RenderBuildTarget --- func TestExtraRenderBuildTargetURI(t *testing.T) { nodes := allNodeTypes()[:2] out, err := RenderBuildTarget(nodes, "uri", "https://x", nil) if err != nil { t.Fatalf("RenderBuildTarget uri failed: %v", err) } if !strings.Contains(out, "://") { t.Errorf("expected URI output: %s", out) } } // --- helper: entries to map --- func entriesToMap(entries [][2]any) map[string]any { m := make(map[string]any) for _, e := range entries { m[e[0].(string)] = e[1] } return m } // --- ensure URL parsing works (used by uri.go) --- func TestExtraURIEncoding(t *testing.T) { // Verify that special chars in passwords/names are properly encoded node := model.ProxyNode{ "type": "trojan", "name": "name with spaces & special", "server": "s", "port": 443, "password": "p@ss/w!th$chars", } uri := ToProxyUri(node) // Should be parseable as a URL u, err := url.Parse(uri) if err != nil { t.Fatalf("uri not parseable: %v", err) } if u.Scheme != "trojan" { t.Errorf("expected trojan scheme, got: %s", u.Scheme) } // name should be URL-encoded in fragment decoded, err := url.QueryUnescape(u.Fragment) if err != nil { t.Fatalf("fragment not decodable: %v", err) } if decoded != "name with spaces & special" { t.Errorf("expected decoded fragment, got: %q", decoded) } } // --- additional regex/filter coverage --- func TestExtraRenderTemplateProxyGroupsEmptyInput(t *testing.T) { result := RenderTemplateProxyGroups(nil, nil) if len(result) != 0 { t.Errorf("expected 0 groups for nil input, got %d", len(result)) } } func TestExtraRenderTemplateProxyGroupsGroupWithOnlyFilter(t *testing.T) { proxies := allNodeTypes()[:3] groups := []map[string]any{ { "name": "HK", "type": "url-test", "filter": "^SS-.*", // anchored to start to avoid matching VLESS-Node }, } result := RenderTemplateProxyGroups(proxies, groups) if len(result) != 1 { t.Fatalf("expected 1 group, got %d", len(result)) } proxiesList, _ := result[0]["proxies"].([]string) if len(proxiesList) != 1 || proxiesList[0] != "SS-Node" { t.Errorf("expected only SS-Node, got: %v", proxiesList) } } // --- additional egern coverage --- func TestExtraEgernVmessSecurityDefault(t *testing.T) { node := model.ProxyNode{ "type": "vmess", "name": "v", "server": "s", "port": 443, "uuid": "u", } m := ToEgernProxy(node) if m["security"] != "auto" { t.Errorf("expected default security=auto for vmess, got: %v", m["security"]) } } func TestExtraEgernVlessNoSecurity(t *testing.T) { node := model.ProxyNode{ "type": "vless", "name": "v", "server": "s", "port": 443, "uuid": "u", } m := ToEgernProxy(node) if s, ok := m["security"]; ok && s != "" { t.Errorf("expected empty security for vless, got: %v", s) } }