Build and Publish Docker Image / build-and-push (pull_request) Successful in 15m54s
- service: EgressCacheKey 从 handler 下沉复用;MergeCachedEgressGeo 在合集 重命名前合并缓存 geo 字段(纯缓存读、零网络 I/O);StripEgressGeoFields 防止 geo 字段泄漏进订阅输出 - filter: geo 检测失败的节点保留 '[别名] 原名'(或纯原名)参与编号/排序, 不再丢弃 - handler: 每小时 StartEgressRefresher 对启用源补探测缺失/过期的 egress 缓存(含 TTL 内 error 结果一律跳过);RegisterRoutes 返回 *Deps; cmd/server.go 启动定时任务并在 shutdown 时取消 - tests: service 缓存 key 稳定性/合并/管道测试、handler 补探测跳过/TTL/ 过期/取消测试、filter 降级测试;修复 2 个过时测试(mihomo YAML 配置、 缓存命中路径)
173 lines
7.3 KiB
Go
173 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|