Files
sub-store/internal/model/model_test.go
T
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

600 lines
17 KiB
Go

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, DownloadGrantRecord, 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("DownloadGrantRecord", func(t *testing.T) {
r := DownloadGrantRecord{ID: "g1", ResourceType: "source", ResourceId: "s1"}
data, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var parsed DownloadGrantRecord
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("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
}