Add 12 test files covering all internal packages: - internal/util/util_test.go (97.2%) - internal/model/model_test.go (100.0%) - internal/config/config_test.go (88.1%) - internal/template/builtin_test.go (98.8%) - internal/middleware/middleware_test.go (98.7%) - internal/database/repo_extra_test.go (85.5%) - internal/rules/converter_test.go (99.1%) - internal/service/subscription_test.go (75.7%) - internal/handler/handler_test.go (90.6%) - internal/filter/filter_extra_test.go (91.9%) - internal/proxy/client_parser_test.go (96.1%) - internal/render/render_extra_test.go (99.2%) Overall: 91.8% (4033/4400 statements) — exceeds 85% acceptance threshold. All tests pass, go vet clean, go build clean.
325 lines
10 KiB
Go
325 lines
10 KiB
Go
package rules
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestConvertRules_BasicMihomo(t *testing.T) {
|
|
content := `DOMAIN,example.com,PROXY
|
|
DOMAIN-SUFFIX,google.com,DIRECT
|
|
DOMAIN-KEYWORD,facebook,PROXY
|
|
IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
|
|
GEOIP,CN,DIRECT
|
|
MATCH,PROXY`
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 6 {
|
|
t.Errorf("expected 6 parsed, got %d", result.Parsed)
|
|
}
|
|
if result.Emitted != 6 {
|
|
t.Errorf("expected 6 emitted, got %d", result.Emitted)
|
|
}
|
|
if !strings.Contains(result.Content, "DOMAIN,example.com,PROXY") {
|
|
t.Errorf("missing DOMAIN rule: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve") {
|
|
t.Errorf("missing IP-CIDR with option: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "MATCH,PROXY") {
|
|
t.Errorf("missing MATCH rule: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_SurgeTarget(t *testing.T) {
|
|
content := "DOMAIN-SUFFIX,google.com,PROXY"
|
|
result := ConvertRules(content, TargetSurge)
|
|
if !strings.Contains(result.Content, "DOMAIN-SUFFIX,google.com,PROXY") {
|
|
t.Errorf("expected DOMAIN-SUFFIX for surge: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_QXTarget(t *testing.T) {
|
|
content := `DOMAIN,example.com,PROXY
|
|
DOMAIN-SUFFIX,google.com,DIRECT
|
|
DOMAIN-KEYWORD,facebook,PROXY
|
|
IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
|
|
IP-CIDR6,2001:db8::/32,DIRECT
|
|
PROCESS-NAME,chrome,DIRECT
|
|
DST-PORT,443,PROXY
|
|
MATCH,PROXY`
|
|
result := ConvertRules(content, TargetQX)
|
|
// QX uses HOST/HOST-SUFFIX/HOST-KEYWORD aliases
|
|
if !strings.Contains(result.Content, "HOST,example.com,PROXY") {
|
|
t.Errorf("expected HOST for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "HOST-SUFFIX,google.com,DIRECT") {
|
|
t.Errorf("expected HOST-SUFFIX for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "HOST-KEYWORD,facebook,PROXY") {
|
|
t.Errorf("expected HOST-KEYWORD for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve") {
|
|
t.Errorf("expected IP-CIDR for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "IP6-CIDR,2001:db8::/32,DIRECT") {
|
|
t.Errorf("expected IP6-CIDR for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "PROCESS-NAME,chrome,DIRECT") {
|
|
t.Errorf("expected PROCESS-NAME for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "DEST-PORT,443,PROXY") {
|
|
t.Errorf("expected DEST-PORT for QX: %s", result.Content)
|
|
}
|
|
if !strings.Contains(result.Content, "FINAL,PROXY") {
|
|
t.Errorf("expected FINAL for QX: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_LoonTarget(t *testing.T) {
|
|
content := "DOMAIN,example.com,PROXY"
|
|
result := ConvertRules(content, TargetLoon)
|
|
if !strings.Contains(result.Content, "DOMAIN,example.com,PROXY") {
|
|
t.Errorf("expected DOMAIN for loon: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_YamlPayloadFormat(t *testing.T) {
|
|
content := `payload:
|
|
- DOMAIN,example.com,PROXY
|
|
- DOMAIN-SUFFIX,google.com,DIRECT
|
|
- MATCH,PROXY`
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 3 {
|
|
t.Errorf("expected 3 parsed, got %d", result.Parsed)
|
|
}
|
|
if !strings.Contains(result.Content, "DOMAIN,example.com,PROXY") {
|
|
t.Errorf("missing DOMAIN rule from yaml: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_RulesYamlFormat(t *testing.T) {
|
|
content := `rules:
|
|
- DOMAIN,example.com,PROXY
|
|
- DOMAIN-SUFFIX,google.com,DIRECT`
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 2 {
|
|
t.Errorf("expected 2 parsed, got %d", result.Parsed)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_EmptyContent(t *testing.T) {
|
|
result := ConvertRules("", TargetMihomo)
|
|
if result.Parsed != 0 {
|
|
t.Errorf("expected 0 parsed, got %d", result.Parsed)
|
|
}
|
|
if result.Content != "" {
|
|
t.Errorf("expected empty content, got %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_CommentsAndEmptyLines(t *testing.T) {
|
|
content := `# This is a comment
|
|
; semicolon comment
|
|
|
|
DOMAIN,example.com,PROXY
|
|
# another comment
|
|
MATCH,PROXY`
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 2 {
|
|
t.Errorf("expected 2 parsed, got %d", result.Parsed)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_QuotedLines(t *testing.T) {
|
|
content := `"DOMAIN,example.com,PROXY"
|
|
'DOMAIN-SUFFIX,google.com,DIRECT'`
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 2 {
|
|
t.Errorf("expected 2 parsed, got %d", result.Parsed)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_UnknownKind(t *testing.T) {
|
|
content := "UNKNOWN-RULE,example.com,PROXY"
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 0 {
|
|
t.Errorf("expected 0 parsed for unknown kind, got %d", result.Parsed)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_KindAliases(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"HOST,example.com,PROXY", "DOMAIN,example.com,PROXY"},
|
|
{"HOST-SUFFIX,google.com,DIRECT", "DOMAIN-SUFFIX,google.com,DIRECT"},
|
|
{"HOST-KEYWORD,facebook,PROXY", "DOMAIN-KEYWORD,facebook,PROXY"},
|
|
{"IPCIDR,10.0.0.0/8,DIRECT", "IP-CIDR,10.0.0.0/8,DIRECT"},
|
|
{"IP6CIDR,2001:db8::/32,DIRECT", "IP-CIDR6,2001:db8::/32,DIRECT"},
|
|
{"PROCESS,chrome,DIRECT", "PROCESS-NAME,chrome,DIRECT"},
|
|
{"DEST-PORT,443,PROXY", "DST-PORT,443,PROXY"},
|
|
{"FINAL,PROXY", "MATCH,PROXY"},
|
|
}
|
|
for _, tc := range tests {
|
|
result := ConvertRules(tc.input, TargetMihomo)
|
|
if !strings.Contains(result.Content, tc.expected) {
|
|
t.Errorf("for input %q, expected %q in output, got %q", tc.input, tc.expected, result.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_MatchWithPolicy(t *testing.T) {
|
|
result := ConvertRules("MATCH,PROXY", TargetMihomo)
|
|
if !strings.Contains(result.Content, "MATCH,PROXY") {
|
|
t.Errorf("expected MATCH,PROXY: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_MatchWithPolicyAndOptions(t *testing.T) {
|
|
result := ConvertRules("MATCH,PROXY,no-resolve", TargetMihomo)
|
|
if !strings.Contains(result.Content, "MATCH,PROXY,no-resolve") {
|
|
t.Errorf("expected MATCH with options: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_QXMatchWithPolicy(t *testing.T) {
|
|
result := ConvertRules("MATCH,PROXY", TargetQX)
|
|
if !strings.Contains(result.Content, "FINAL,PROXY") {
|
|
t.Errorf("expected FINAL,PROXY for QX: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_RuleWithNoValue(t *testing.T) {
|
|
// DOMAIN with no value -> should be skipped (parseRuleLine returns nil)
|
|
result := ConvertRules("DOMAIN,", TargetMihomo)
|
|
if result.Parsed != 0 {
|
|
t.Errorf("expected 0 parsed for DOMAIN with no value, got %d", result.Parsed)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_MatchWithOptions(t *testing.T) {
|
|
content := "MATCH,PROXY,extended-matching"
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if !strings.Contains(result.Content, "MATCH,PROXY,extended-matching") {
|
|
t.Errorf("expected MATCH with options: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_RuleWithOptions(t *testing.T) {
|
|
content := "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve,extended-matching"
|
|
result := ConvertRules(content, TargetMihomo)
|
|
// no-resolve and extended-matching are options; DIRECT is the policy
|
|
if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve,extended-matching") {
|
|
t.Errorf("expected options preserved: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_QXFiltersNonNoResolveOptions(t *testing.T) {
|
|
content := "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve,extended-matching"
|
|
result := ConvertRules(content, TargetQX)
|
|
// QX only keeps no-resolve, drops extended-matching
|
|
if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve") {
|
|
t.Errorf("expected no-resolve kept for QX: %s", result.Content)
|
|
}
|
|
if strings.Contains(result.Content, "extended-matching") {
|
|
t.Errorf("expected extended-matching dropped for QX: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_NoPolicy(t *testing.T) {
|
|
content := "DOMAIN,example.com"
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if result.Parsed != 1 {
|
|
t.Errorf("expected 1 parsed, got %d", result.Parsed)
|
|
}
|
|
if !strings.Contains(result.Content, "DOMAIN,example.com") {
|
|
t.Errorf("expected DOMAIN,example.com: %s", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestParseRuleLine_Empty(t *testing.T) {
|
|
if parseRuleLine("") != nil {
|
|
t.Error("expected nil for empty line")
|
|
}
|
|
}
|
|
|
|
func TestSplitCsv_Simple(t *testing.T) {
|
|
parts := splitCsv("a,b,c")
|
|
if len(parts) != 3 || parts[0] != "a" || parts[1] != "b" || parts[2] != "c" {
|
|
t.Errorf("expected [a b c], got %v", parts)
|
|
}
|
|
}
|
|
|
|
func TestSplitCsv_WithQuotes(t *testing.T) {
|
|
parts := splitCsv(`a,"b,c",d`)
|
|
if len(parts) != 3 || parts[0] != "a" || parts[1] != "b,c" || parts[2] != "d" {
|
|
t.Errorf("expected [a b,c d], got %v", parts)
|
|
}
|
|
}
|
|
|
|
func TestSplitCsv_SingleQuotes(t *testing.T) {
|
|
parts := splitCsv(`a,'b,c',d`)
|
|
if len(parts) != 3 || parts[0] != "a" || parts[1] != "b,c" || parts[2] != "d" {
|
|
t.Errorf("expected [a b,c d], got %v", parts)
|
|
}
|
|
}
|
|
|
|
func TestSplitCsv_EmptyFields(t *testing.T) {
|
|
parts := splitCsv("a,,c")
|
|
// Empty middle field is trimmed to "" and should be included
|
|
if len(parts) != 3 {
|
|
t.Errorf("expected 3 parts, got %d: %v", len(parts), parts)
|
|
}
|
|
}
|
|
|
|
func TestSplitCsv_TrailingEmpty(t *testing.T) {
|
|
parts := splitCsv("a,b,")
|
|
// Trailing empty should not be included
|
|
if len(parts) != 2 {
|
|
t.Errorf("expected 2 parts, got %d: %v", len(parts), parts)
|
|
}
|
|
}
|
|
|
|
func TestQxKind_KnownAndUnknown(t *testing.T) {
|
|
if qxKind("DOMAIN") != "HOST" {
|
|
t.Error("expected HOST for DOMAIN")
|
|
}
|
|
if qxKind("DOMAIN-SUFFIX") != "HOST-SUFFIX" {
|
|
t.Error("expected HOST-SUFFIX")
|
|
}
|
|
if qxKind("IP-CIDR6") != "IP6-CIDR" {
|
|
t.Error("expected IP6-CIDR")
|
|
}
|
|
// Unknown kind returns itself
|
|
if qxKind("UNKNOWN") != "UNKNOWN" {
|
|
t.Error("expected UNKNOWN to pass through")
|
|
}
|
|
}
|
|
|
|
func TestExtractRuleLines_OnlyComments(t *testing.T) {
|
|
lines := extractRuleLines("# comment\n; another\n")
|
|
if len(lines) != 0 {
|
|
t.Errorf("expected 0 lines, got %d", len(lines))
|
|
}
|
|
}
|
|
|
|
func TestExtractRuleLines_YamlWithComments(t *testing.T) {
|
|
content := `payload:
|
|
- DOMAIN,example.com,PROXY
|
|
# comment
|
|
- MATCH,DIRECT`
|
|
lines := extractRuleLines(content)
|
|
if len(lines) != 2 {
|
|
t.Errorf("expected 2 lines, got %d: %v", len(lines), lines)
|
|
}
|
|
}
|
|
|
|
func TestConvertRules_GeoSite(t *testing.T) {
|
|
content := "GEOSITE,category-ads,DIRECT"
|
|
result := ConvertRules(content, TargetMihomo)
|
|
if !strings.Contains(result.Content, "GEOSITE,category-ads,DIRECT") {
|
|
t.Errorf("expected GEOSITE rule: %s", result.Content)
|
|
}
|
|
}
|