package service import ( "context" "encoding/json" "path/filepath" "strings" "testing" "github.com/jmoiron/sqlx" _ "modernc.org/sqlite" "github.com/peterqiu0516/sub-store/internal/database" "github.com/peterqiu0516/sub-store/internal/model" ) func newTestCacheRepo(t *testing.T) *database.CacheRepo { t.Helper() dir := t.TempDir() db, err := sqlx.Open("sqlite", filepath.Join(dir, "test.db")+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)") if err != nil { t.Fatalf("failed to open db: %v", err) } t.Cleanup(func() { db.Close() }) if err := database.RunMigrations(db); err != nil { t.Fatalf("failed to run migrations: %v", err) } return database.NewCacheRepo(db) } const usGeoJson = `{"egressIp":"67.215.229.50","country":"United States","countryCode":"US","region":"California","city":"Los Angeles","flag":"🇺🇸"}` // realityVlessContent mirrors the production self-built source: two nodes // whose names carry geo hints, one (racknerd-la) that only the egress cache // can resolve. var realityVlessContent = strings.Join([]string{ "vless://00000000-0000-0000-0000-000000000001@8.220.220.41:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=dl.google.com&fp=chrome&pbk=public-key-1&sid=short-id-1&spx=%2F&type=tcp&headerType=none#ali-seoul", "vless://00000000-0000-0000-0000-000000000002@8.216.16.28:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.yahoo.co.jp&fp=chrome&pbk=public-key-2&sid=short-id-2&spx=%2F&type=tcp&headerType=none#Ali-Tokyo", "vless://00000000-0000-0000-0000-000000000003@67.215.229.50:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.cloudflare.com&fp=chrome&pbk=public-key-3&sid=short-id-3&spx=%2F&type=tcp&headerType=none#racknerd-la", }, "\n") func TestEgressCacheKey_IgnoresVolatileFields(t *testing.T) { base := model.ProxyNode{"type": "vless", "server": "1.2.3.4", "port": 443, "uuid": "u"} decorated := model.ProxyNode{} for k, v := range base { decorated[k] = v } for k, v := range map[string]any{ "name": "renamed", "id": "p1", "_sourceAlias": "自建", "_previewId": "x", "latencyMs": 12.0, "country": "US", "countryCode": "US", "city": "LA", "region": "CA", "flag": "🇺🇸", "cached": true, "egressError": "e", "egressIp": "1.1.1.1", } { decorated[k] = v } if EgressCacheKey(base) != EgressCacheKey(decorated) { t.Error("cache key must ignore volatile/geo decoration fields") } // int vs float64 port marshal identically — key is stable across // pipeline stages and JSON round-trips. asFloat := model.ProxyNode{"type": "vless", "server": "1.2.3.4", "port": float64(443), "uuid": "u"} if EgressCacheKey(base) != EgressCacheKey(asFloat) { t.Error("cache key must be stable across int/float64 port representations") } changed := model.ProxyNode{"type": "vless", "server": "1.2.3.4", "port": 8443, "uuid": "u"} if EgressCacheKey(base) == EgressCacheKey(changed) { t.Error("cache key must change when connection fields change") } } func TestMergeCachedEgressGeo(t *testing.T) { repo := newTestCacheRepo(t) node := model.ProxyNode{"type": "vless", "name": "n1", "server": "1.2.3.4", "port": 443, "uuid": "u"} repo.SafePut(EgressCacheKey(node), usGeoJson, nil, 300) merged := MergeCachedEgressGeo(repo, []model.ProxyNode{node}) if merged[0]["country"] != "United States" || merged[0]["countryCode"] != "US" || merged[0]["flag"] != "🇺🇸" { t.Fatalf("geo fields not merged: %v", merged[0]) } if _, ok := merged[0]["cached"]; ok { t.Error("pipeline merge must not set the cached marker") } // Cache miss leaves the node untouched. miss := model.ProxyNode{"type": "vless", "name": "n2", "server": "5.6.7.8", "port": 443, "uuid": "u"} out := MergeCachedEgressGeo(repo, []model.ProxyNode{miss}) if _, ok := out[0]["country"]; ok { t.Error("cache miss must not add geo fields") } } func TestBuildSubscriptionResult_CollectionRenameMergesEgressCache(t *testing.T) { repo := newTestCacheRepo(t) src := model.SourceRecord{ID: "self", Name: "self", Alias: "自建", Type: "local", Content: realityVlessContent, Enabled: true} collection := &model.CollectionRecord{ ID: "daily", Name: "daily", SourceIds: []string{"self"}, RenameEnabled: true, // Production "daily" options: flag + alias + country + index, city off. RenameOptions: &model.RenameOptions{Flag: true, Alias: true, Country: true, City: false, Index: true}, Enabled: true, } // Step 1: build without rename to obtain the parsed nodes and seed the // egress cache for racknerd-la (the node name regex cannot resolve). plain := &model.CollectionRecord{ID: "daily", Name: "daily", SourceIds: []string{"self"}, Enabled: true} base, err := BuildSubscriptionResult(context.Background(), BuildOptions{Collection: plain, Sources: []model.SourceRecord{src}, Target: "json"}) if err != nil { t.Fatalf("base build failed: %v", err) } var payload struct { Proxies []model.ProxyNode `json:"proxies"` } if err := json.Unmarshal([]byte(base.Body), &payload); err != nil { t.Fatalf("parse base body: %v", err) } seeded := 0 for _, n := range payload.Proxies { if n["name"] == "racknerd-la" { repo.SafePut(EgressCacheKey(n), usGeoJson, nil, 300) seeded++ } } if seeded != 1 { t.Fatalf("expected to seed exactly one racknerd-la node, seeded %d", seeded) } // Step 2: renamed collection build must read the cache (no probing) and // keep all three nodes. result, err := BuildSubscriptionResult(context.Background(), BuildOptions{ Collection: collection, Sources: []model.SourceRecord{src}, Target: "json", CacheRepo: repo, }) if err != nil { t.Fatalf("rename build failed: %v", err) } if result.Nodes != 3 { t.Fatalf("expected 3 nodes, got %d: %s", result.Nodes, result.Body) } if !strings.Contains(result.Body, "🇺🇸 [自建] 美国") { t.Errorf("racknerd-la should render as %q via cached geo, body: %s", "🇺🇸 [自建] 美国", result.Body) } if !strings.Contains(result.Body, "🇰🇷 [自建] 韩国") { t.Errorf("ali-seoul should keep name-based geo, body: %s", result.Body) } // Merged geo fields must not leak into the subscription output. if strings.Contains(result.Body, `"countryCode"`) || strings.Contains(result.Body, `"egressIp"`) { t.Errorf("merged cache fields leaked into output: %s", result.Body) } } // Without a cache repo the rename pipeline still works via name fallback and // geo-unknown nodes are kept under their original (alias-prefixed) names. func TestBuildSubscriptionResult_RenameKeepsGeoUnknownNodes(t *testing.T) { src := model.SourceRecord{ID: "self", Name: "self", Alias: "自建", Type: "local", Content: realityVlessContent, Enabled: true} collection := &model.CollectionRecord{ ID: "daily", Name: "daily", SourceIds: []string{"self"}, RenameEnabled: true, RenameOptions: &model.RenameOptions{Flag: true, Alias: true, Country: true, City: false, Index: true}, Enabled: true, } result, err := BuildSubscriptionResult(context.Background(), BuildOptions{Collection: collection, Sources: []model.SourceRecord{src}, Target: "json"}) if err != nil { t.Fatalf("build failed: %v", err) } if result.Nodes != 3 { t.Fatalf("expected all 3 nodes kept, got %d: %s", result.Nodes, result.Body) } if !strings.Contains(result.Body, "[自建] racknerd-la") { t.Errorf("geo-unknown node should fall back to alias + original name, body: %s", result.Body) } }