package model import ( "encoding/json" "testing" ) // ---------- target.go ---------- func TestNormalizeTarget(t *testing.T) { tests := []struct { name string target string ua string want string }{ // Explicit target takes priority {"explicit mihomo", "mihomo", "sing-box/1.0", TargetMihomo}, {"explicit clash alias", "clash", "", TargetMihomo}, {"explicit clashmeta alias", "clashmeta", "", TargetMihomo}, {"explicit meta alias", "meta", "", TargetMihomo}, {"explicit stash", "stash", "", TargetStash}, {"explicit surge", "surge", "", TargetSurge}, {"explicit surge-mac", "surge-mac", "", TargetSurgeMac}, {"explicit surfboard", "surfboard", "", TargetSurfboard}, {"explicit loon", "loon", "", TargetLoon}, {"explicit egern", "egern", "", TargetEgern}, {"explicit shadowrocket", "shadowrocket", "", TargetShadowrocket}, {"explicit sr alias", "sr", "", TargetShadowrocket}, {"explicit qx", "qx", "", TargetQX}, {"explicit quanx alias", "quanx", "", TargetQX}, {"explicit sing-box", "sing-box", "", TargetSingBox}, {"explicit singbox alias", "singbox", "", TargetSingBox}, {"explicit sfa alias", "sfa", "", TargetSingBox}, {"explicit karing alias", "karing", "", TargetSingBox}, {"explicit v2ray", "v2ray", "", TargetV2ray}, {"explicit v2rayn alias", "v2rayn", "", TargetV2ray}, {"explicit v2rayng alias", "v2rayng", "", TargetV2ray}, {"explicit base64 alias", "base64", "", TargetV2ray}, {"explicit uri", "uri", "", TargetURI}, {"explicit uris alias", "uris", "", TargetURI}, {"explicit plain alias", "plain", "", TargetURI}, {"explicit text alias", "text", "", TargetURI}, {"explicit json", "json", "", TargetJSON}, {"explicit raw alias", "raw", "", TargetJSON}, // UA inference (no explicit target) {"ua sing-box", "", "sing-box/1.7", TargetSingBox}, {"ua singbox", "", "SINGBOX", TargetSingBox}, {"ua v2ray", "", "v2rayN/6.0", TargetV2ray}, {"ua surge mac", "", "Surge/4.5 Mac", TargetSurgeMac}, {"ua surge no mac", "", "Surge/4.5", TargetSurge}, {"ua loon", "", "Loon/3.0", TargetLoon}, {"ua egern", "", "Egern/1.0", TargetEgern}, {"ua shadowrocket", "", "Shadowrocket/2.0", TargetShadowrocket}, {"ua quantumult", "", "Quantumult%20X/1.0", TargetQX}, {"ua stash", "", "Stash/2.0", TargetStash}, {"ua unknown defaults mihomo", "", "UnknownBrowser/1.0", TargetMihomo}, {"both empty", "", "", TargetMihomo}, // Case-insensitive target {"uppercase target", "SURGE", "", TargetSurge}, {"mixed case target", "Sing-Box", "", TargetSingBox}, // Unknown target falls through to UA {"unknown target with ua", "unknown", "surge/1.0", TargetSurge}, {"unknown target no ua", "unknown", "", TargetMihomo}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := NormalizeTarget(tt.target, tt.ua) if got != tt.want { t.Errorf("NormalizeTarget(%q, %q) = %q, want %q", tt.target, tt.ua, got, tt.want) } }) } } func TestNormalizeTargetAlias(t *testing.T) { // All aliases should map correctly aliases := map[string]string{ "clash": TargetMihomo, "clashmeta": TargetMihomo, "clash-meta": TargetMihomo, "meta": TargetMihomo, "mihomo": TargetMihomo, "stash": TargetStash, "surge": TargetSurge, "surgemac": TargetSurgeMac, "surge-mac": TargetSurgeMac, "surge_mac": TargetSurgeMac, "surfboard": TargetSurfboard, "loon": TargetLoon, "egern": TargetEgern, "shadowrocket": TargetShadowrocket, "sr": TargetShadowrocket, "qx": TargetQX, "quanx": TargetQX, "quantumult": TargetQX, "quantumultx": TargetQX, "quantumult-x": TargetQX, "singbox": TargetSingBox, "sing-box": TargetSingBox, "sfa": TargetSingBox, "karing": TargetSingBox, "v2ray": TargetV2ray, "v2rayn": TargetV2ray, "v2rayng": TargetV2ray, "base64": TargetV2ray, "uri": TargetURI, "uris": TargetURI, "plain": TargetURI, "text": TargetURI, "json": TargetJSON, "raw": TargetJSON, } for alias, want := range aliases { t.Run("alias_"+alias, func(t *testing.T) { if got := NormalizeTargetAlias(alias); got != want { t.Errorf("NormalizeTargetAlias(%q) = %q, want %q", alias, got, want) } }) } // nil / empty / unknown if got := NormalizeTargetAlias(nil); got != "" { t.Errorf("NormalizeTargetAlias(nil) = %q, want empty", got) } if got := NormalizeTargetAlias(""); got != "" { t.Errorf("NormalizeTargetAlias(\"\") = %q, want empty", got) } if got := NormalizeTargetAlias("unknownalias"); got != "" { t.Errorf("NormalizeTargetAlias(unknown) = %q, want empty", got) } // non-string type if got := NormalizeTargetAlias(123); got != "" { t.Errorf("NormalizeTargetAlias(123) = %q, want empty", got) } } func TestIsTargetCompatible(t *testing.T) { // mihomo/stash/json accept everything if !IsTargetCompatible("ss", TargetMihomo) { t.Error("mihomo should be compatible with ss") } if !IsTargetCompatible("anything", TargetStash) { t.Error("stash should be compatible with anything") } if !IsTargetCompatible("anything", TargetJSON) { t.Error("json should be compatible with anything") } // URI/V2ray/Shadowrocket uriTypes := []string{"ss", "ssr", "vmess", "vless", "trojan", "hysteria", "hysteria2", "tuic", "anytls", "http", "socks5", "wireguard"} for _, pt := range uriTypes { if !IsTargetCompatible(pt, TargetURI) { t.Errorf("URI should be compatible with %s", pt) } if !IsTargetCompatible(pt, TargetV2ray) { t.Errorf("V2ray should be compatible with %s", pt) } if !IsTargetCompatible(pt, TargetShadowrocket) { t.Errorf("Shadowrocket should be compatible with %s", pt) } } if IsTargetCompatible("unknown", TargetURI) { t.Error("URI should not be compatible with unknown") } // SingBox — note: no ssr in singbox list if !IsTargetCompatible("ss", TargetSingBox) { t.Error("sing-box should be compatible with ss") } if IsTargetCompatible("ssr", TargetSingBox) { t.Error("sing-box should NOT be compatible with ssr") } // Surge if !IsTargetCompatible("ss", TargetSurge) { t.Error("surge should be compatible with ss") } if !IsTargetCompatible("snell", TargetSurge) { t.Error("surge should be compatible with snell") } if IsTargetCompatible("ssr", TargetSurge) { t.Error("surge should NOT be compatible with ssr") } // SurgeMac — has ssh and h2-connect if !IsTargetCompatible("ssh", TargetSurgeMac) { t.Error("surge-mac should be compatible with ssh") } if !IsTargetCompatible("h2-connect", TargetSurgeMac) { t.Error("surge-mac should be compatible with h2-connect") } // Surfboard if !IsTargetCompatible("ss", TargetSurfboard) { t.Error("surfboard should be compatible with ss") } if IsTargetCompatible("hysteria2", TargetSurfboard) { t.Error("surfboard should NOT be compatible with hysteria2") } // Loon if !IsTargetCompatible("ssr", TargetLoon) { t.Error("loon should be compatible with ssr") } if !IsTargetCompatible("wireguard", TargetLoon) { t.Error("loon should be compatible with wireguard") } // QX if !IsTargetCompatible("ssr", TargetQX) { t.Error("qx should be compatible with ssr") } if !IsTargetCompatible("anytls", TargetQX) { t.Error("qx should be compatible with anytls") } // Egern if !IsTargetCompatible("ss", TargetEgern) { t.Error("egern should be compatible with ss") } if IsTargetCompatible("ssr", TargetEgern) { t.Error("egern should NOT be compatible with ssr") } // Unknown target if IsTargetCompatible("ss", "unknown-target") { t.Error("unknown target should not be compatible") } } func TestGetTargetContentType(t *testing.T) { tests := []struct { target string want string }{ {TargetSingBox, "application/json; charset=utf-8"}, {TargetJSON, "application/json; charset=utf-8"}, {TargetV2ray, "text/plain; charset=utf-8"}, {TargetURI, "text/plain; charset=utf-8"}, {TargetSurge, "text/plain; charset=utf-8"}, {TargetSurgeMac, "text/plain; charset=utf-8"}, {TargetSurfboard, "text/plain; charset=utf-8"}, {TargetLoon, "text/plain; charset=utf-8"}, {TargetShadowrocket, "text/plain; charset=utf-8"}, {TargetQX, "text/plain; charset=utf-8"}, {TargetMihomo, "text/yaml; charset=utf-8"}, {TargetStash, "text/yaml; charset=utf-8"}, {"unknown", "text/yaml; charset=utf-8"}, } for _, tt := range tests { t.Run(tt.target, func(t *testing.T) { if got := GetTargetContentType(tt.target); got != tt.want { t.Errorf("GetTargetContentType(%q) = %q, want %q", tt.target, got, tt.want) } }) } } // ---------- response.go ---------- func TestSuccessResponse(t *testing.T) { resp := SuccessResponse("hello") if resp.Status != "success" { t.Errorf("Status = %q, want success", resp.Status) } if resp.Data != "hello" { t.Errorf("Data = %v, want hello", resp.Data) } if resp.Error != nil { t.Error("Error should be nil for success") } } func TestFailedResponse(t *testing.T) { resp := FailedResponse("bad request", 400) if resp.Status != "failed" { t.Errorf("Status = %q, want failed", resp.Status) } if resp.Data != nil { t.Error("Data should be nil for failed") } if resp.Error == nil { t.Fatal("Error should not be nil") } if resp.Error.Code != 400 { t.Errorf("Error.Code = %d, want 400", resp.Error.Code) } if resp.Error.Message != "bad request" { t.Errorf("Error.Message = %q, want bad request", resp.Error.Message) } } func TestSuccessResponseJSON(t *testing.T) { // Verify it serializes correctly resp := SuccessResponse(map[string]int{"count": 3}) data, err := json.Marshal(resp) if err != nil { t.Fatalf("marshal error: %v", err) } var parsed APIResponse if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal error: %v", err) } if parsed.Status != "success" { t.Errorf("Status = %q, want success", parsed.Status) } } func TestFailedResponseJSON(t *testing.T) { resp := FailedResponse("not found", 404) data, err := json.Marshal(resp) if err != nil { t.Fatalf("marshal error: %v", err) } s := string(data) if !containsStr(s, "not found") { t.Errorf("JSON should contain message: %s", s) } if !containsStr(s, "404") { t.Errorf("JSON should contain code: %s", s) } } // ---------- UnmarshalFilterRules ---------- func TestUnmarshalFilterRules(t *testing.T) { t.Run("empty string", func(t *testing.T) { rules := UnmarshalFilterRules("") if len(rules) != 0 { t.Errorf("empty string should return empty slice, got %d", len(rules)) } }) t.Run("empty array", func(t *testing.T) { rules := UnmarshalFilterRules("[]") if len(rules) != 0 { t.Errorf("[] should return empty slice, got %d", len(rules)) } }) t.Run("valid rules", func(t *testing.T) { data := `[{"type":"name","pattern":"HK"},{"type":"sort","direction":"asc"}]` rules := UnmarshalFilterRules(data) if len(rules) != 2 { t.Fatalf("expected 2 rules, got %d", len(rules)) } if rules[0].Type != "name" { t.Errorf("rules[0].Type = %q, want name", rules[0].Type) } if rules[0].Pattern != "HK" { t.Errorf("rules[0].Pattern = %q, want HK", rules[0].Pattern) } if rules[1].Type != "sort" { t.Errorf("rules[1].Type = %q, want sort", rules[1].Type) } if rules[1].Direction != "asc" { t.Errorf("rules[1].Direction = %q, want asc", rules[1].Direction) } }) t.Run("invalid json", func(t *testing.T) { rules := UnmarshalFilterRules("!!!invalid") if len(rules) != 0 { t.Errorf("invalid json should return empty slice, got %d", len(rules)) } }) t.Run("null json", func(t *testing.T) { rules := UnmarshalFilterRules("null") if len(rules) != 0 { t.Errorf("null should return empty slice, got %d", len(rules)) } }) } func TestUnmarshalMeta(t *testing.T) { t.Run("empty string", func(t *testing.T) { m := UnmarshalMeta("") if len(m) != 0 { t.Errorf("empty string should return empty map, got %d", len(m)) } }) t.Run("empty object", func(t *testing.T) { m := UnmarshalMeta("{}") if len(m) != 0 { t.Errorf("{} should return empty map, got %d", len(m)) } }) t.Run("valid meta", func(t *testing.T) { data := `{"key":"value","count":3}` m := UnmarshalMeta(data) if m["key"] != "value" { t.Errorf("m[key] = %v, want value", m["key"]) } // JSON numbers become float64 if c, ok := m["count"].(float64); !ok || c != 3 { t.Errorf("m[count] = %v, want 3", m["count"]) } }) t.Run("invalid json", func(t *testing.T) { m := UnmarshalMeta("!!!invalid") if len(m) != 0 { t.Errorf("invalid json should return empty map, got %d", len(m)) } }) t.Run("null json", func(t *testing.T) { m := UnmarshalMeta("null") if len(m) != 0 { t.Errorf("null should return empty map, got %d", len(m)) } }) } // ---------- types.go (struct round-trips) ---------- func TestProxyNodeRoundTrip(t *testing.T) { node := ProxyNode{ "type": "ss", "name": "Test", "server": "1.2.3.4", "port": 8388, } data, err := json.Marshal(node) if err != nil { t.Fatalf("marshal error: %v", err) } var parsed ProxyNode if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal error: %v", err) } if parsed["type"] != "ss" { t.Errorf("type = %v, want ss", parsed["type"]) } if parsed["name"] != "Test" { t.Errorf("name = %v, want Test", parsed["name"]) } } func TestFilterRuleJSON(t *testing.T) { rule := FilterRule{ Type: "name", Pattern: "HK", Args: map[string]any{"key": "val"}, Rules: []CustomRule{{Action: "set", Field: "name"}}, } data, err := json.Marshal(rule) if err != nil { t.Fatalf("marshal error: %v", err) } var parsed FilterRule if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal error: %v", err) } if parsed.Type != "name" { t.Errorf("Type = %q, want name", parsed.Type) } if parsed.Pattern != "HK" { t.Errorf("Pattern = %q, want HK", parsed.Pattern) } if len(parsed.Rules) != 1 { t.Errorf("Rules len = %d, want 1", len(parsed.Rules)) } } func TestRecordStructsJSON(t *testing.T) { // Verify SourceRecord, CollectionRecord, TemplateRecord, RecycleBinRecord // can marshal/unmarshal without errors. t.Run("SourceRecord", func(t *testing.T) { r := SourceRecord{ID: "s1", Name: "test", Type: "remote", URL: "http://example.com"} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } var parsed SourceRecord if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } if parsed.ID != "s1" { t.Errorf("ID = %q, want s1", parsed.ID) } }) t.Run("CollectionRecord", func(t *testing.T) { r := CollectionRecord{ID: "c1", Name: "coll", SourceIds: []string{"s1", "s2"}} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } var parsed CollectionRecord if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } if len(parsed.SourceIds) != 2 { t.Errorf("SourceIds len = %d, want 2", len(parsed.SourceIds)) } }) t.Run("TemplateRecord", func(t *testing.T) { r := TemplateRecord{ID: "t1", Name: "tmpl", Target: "mihomo", Config: map[string]any{"k": "v"}} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } var parsed TemplateRecord if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } if parsed.Target != "mihomo" { t.Errorf("Target = %q, want mihomo", parsed.Target) } }) t.Run("RecycleBinRecord", func(t *testing.T) { r := RecycleBinRecord{ID: "r1", ResourceType: "source", ResourceId: "s1", Snapshot: map[string]any{"k": "v"}} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } var parsed RecycleBinRecord if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } if parsed.ResourceType != "source" { t.Errorf("ResourceType = %q, want source", parsed.ResourceType) } }) t.Run("AppConfig", func(t *testing.T) { r := AppConfig{ Sources: []SourceRecord{{ID: "s1"}}, Collections: []CollectionRecord{{ID: "c1"}}, Templates: []TemplateRecord{{ID: "t1"}}, Settings: map[string]any{"k": "v"}, } data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } var parsed AppConfig if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } if len(parsed.Sources) != 1 { t.Errorf("Sources len = %d, want 1", len(parsed.Sources)) } }) t.Run("CustomRule", func(t *testing.T) { r := CustomRule{Action: "set", Field: "name", Value: "newname"} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } var parsed CustomRule if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } if parsed.Action != "set" { t.Errorf("Action = %q, want set", parsed.Action) } }) } // containsStr helper for string-in-string checks (avoids collision with target.go's contains) func containsStr(s, substr string) bool { return len(s) >= len(substr) && (s == substr || indexOfStr(s, substr) >= 0) } func indexOfStr(s, sub string) int { for i := 0; i <= len(s)-len(sub); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }