Files
rogee cec95ff4e8 test: add comprehensive unit tests — coverage 20.2% → 91.8%
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.
2026-07-27 16:12:28 +08:00

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)
}
}