817 lines
23 KiB
Go
817 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/peterqiu0516/sub-store/internal/model"
|
|
)
|
|
|
|
// --- BuildSubscriptionResult with local sources ---
|
|
|
|
func TestBuildSubscriptionResult_LocalSource(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1\nss://pass@host:81#Node2",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "json",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Nodes != 2 {
|
|
t.Errorf("expected 2 nodes, got %d", result.Nodes)
|
|
}
|
|
if result.Body == "" {
|
|
t.Error("expected non-empty body")
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_RealityVlessSubscription(t *testing.T) {
|
|
content := 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")
|
|
source := &model.SourceRecord{ID: "reality", Type: "local", Content: content, Enabled: true}
|
|
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{Source: source, Target: "json"})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.OriginalNodes != 3 || result.Nodes != 3 {
|
|
t.Fatalf("nodes = %d/%d, want 3/3", result.OriginalNodes, result.Nodes)
|
|
}
|
|
var output struct {
|
|
Proxies []model.ProxyNode `json:"proxies"`
|
|
}
|
|
if err := json.Unmarshal([]byte(result.Body), &output); err != nil {
|
|
t.Fatalf("decode output: %v", err)
|
|
}
|
|
for _, node := range output.Proxies {
|
|
if node["type"] != "vless" || node["flow"] != "xtls-rprx-vision" || node["network"] != "tcp" {
|
|
t.Fatalf("VLESS fields lost: %#v", node)
|
|
}
|
|
if reality, ok := node["reality-opts"].(map[string]any); !ok || reality["public-key"] == "" || reality["short-id"] == "" {
|
|
t.Fatalf("Reality fields lost: %#v", node["reality-opts"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_RemoteFetchError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "blocked", http.StatusForbidden)
|
|
}))
|
|
defer server.Close()
|
|
source := &model.SourceRecord{ID: "blocked", Name: "blocked", Type: "remote", URL: server.URL, Enabled: true}
|
|
|
|
_, err := BuildSubscriptionResult(context.Background(), BuildOptions{Source: source, Target: "json"})
|
|
if err == nil || !strings.Contains(err.Error(), "Remote source blocked failed: 403") {
|
|
t.Fatalf("error = %v, want upstream 403", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_NoEnabledSources(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1",
|
|
Enabled: false,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
_, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "json",
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for no enabled sources")
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_EmptyContent(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
_, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "json",
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for empty content (no nodes)")
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_Collection(t *testing.T) {
|
|
src1 := model.SourceRecord{
|
|
ID: "src1", Name: "Src1", Type: "local",
|
|
Content: "ss://pass@host:80#Node1", Enabled: true,
|
|
Filters: []model.FilterRule{}, Meta: map[string]any{},
|
|
}
|
|
src2 := model.SourceRecord{
|
|
ID: "src2", Name: "Src2", Type: "local",
|
|
Content: "ss://pass@host:81#Node2", Enabled: true,
|
|
Filters: []model.FilterRule{}, Meta: map[string]any{},
|
|
}
|
|
collection := &model.CollectionRecord{
|
|
ID: "col1",
|
|
Name: "Collection",
|
|
SourceIds: []string{"src1", "src2"},
|
|
TemplateId: "acl4ssr-mihomo",
|
|
Enabled: true,
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Collection: collection,
|
|
Sources: []model.SourceRecord{src1, src2},
|
|
Target: "json",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Nodes != 2 {
|
|
t.Errorf("expected 2 nodes, got %d", result.Nodes)
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_CollectionIgnoreFailed(t *testing.T) {
|
|
src1 := model.SourceRecord{
|
|
ID: "src1", Name: "Src1", Type: "local",
|
|
Content: "ss://pass@host:80#Node1", Enabled: true,
|
|
Filters: []model.FilterRule{}, Meta: map[string]any{},
|
|
}
|
|
src2 := model.SourceRecord{
|
|
ID: "src2", Name: "Src2", Type: "local",
|
|
Content: "", Enabled: true,
|
|
Filters: []model.FilterRule{}, Meta: map[string]any{},
|
|
}
|
|
collection := &model.CollectionRecord{
|
|
ID: "col1",
|
|
Name: "Collection",
|
|
SourceIds: []string{"src1", "src2"},
|
|
TemplateId: "acl4ssr-mihomo",
|
|
IgnoreFailed: true,
|
|
Enabled: true,
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Collection: collection,
|
|
Sources: []model.SourceRecord{src1, src2},
|
|
Target: "json",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
// src2 has empty content so produces 0 nodes, but IgnoreFailed should still return src1's node
|
|
if result.Nodes != 1 {
|
|
t.Errorf("expected 1 node, got %d", result.Nodes)
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_WithFilters(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#HK-Node\nss://pass@host:81#US-Node",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{
|
|
{Type: "include", Field: "name", Pattern: "HK"},
|
|
},
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "json",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Nodes != 1 {
|
|
t.Errorf("expected 1 node after filter, got %d", result.Nodes)
|
|
}
|
|
if result.OriginalNodes != 2 {
|
|
t.Errorf("expected 2 original nodes, got %d", result.OriginalNodes)
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_MihomoTarget(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "mihomo",
|
|
TemplateConfig: map[string]any{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Body == "" {
|
|
t.Error("expected non-empty body")
|
|
}
|
|
}
|
|
|
|
// --- getSources ---
|
|
|
|
func TestGetSources_WithSource(t *testing.T) {
|
|
src := &model.SourceRecord{ID: "s1", Name: "S1"}
|
|
opts := BuildOptions{Source: src}
|
|
result := getSources(opts)
|
|
if len(result) != 1 || result[0].ID != "s1" {
|
|
t.Errorf("expected [s1], got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestGetSources_NoSourceNoCollection(t *testing.T) {
|
|
opts := BuildOptions{}
|
|
result := getSources(opts)
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestGetSources_CollectionWithSourceIds(t *testing.T) {
|
|
col := &model.CollectionRecord{ID: "c1", SourceIds: []string{"s1", "s2"}}
|
|
sources := []model.SourceRecord{
|
|
{ID: "s1", Name: "Source1"},
|
|
{ID: "s2", Name: "Source2"},
|
|
{ID: "s3", Name: "Source3"},
|
|
}
|
|
opts := BuildOptions{Collection: col, Sources: sources}
|
|
result := getSources(opts)
|
|
if len(result) != 2 {
|
|
t.Errorf("expected 2 sources, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestGetSources_CollectionByName(t *testing.T) {
|
|
col := &model.CollectionRecord{ID: "c1", SourceIds: []string{"Source1"}}
|
|
sources := []model.SourceRecord{
|
|
{ID: "s1", Name: "Source1"},
|
|
}
|
|
opts := BuildOptions{Collection: col, Sources: sources}
|
|
result := getSources(opts)
|
|
if len(result) != 1 {
|
|
t.Errorf("expected 1 source matched by name, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestGetSources_CollectionEmptySourceIds(t *testing.T) {
|
|
col := &model.CollectionRecord{ID: "c1", SourceIds: []string{}}
|
|
sources := []model.SourceRecord{{ID: "s1"}}
|
|
opts := BuildOptions{Collection: col, Sources: sources}
|
|
result := getSources(opts)
|
|
if len(result) != 1 {
|
|
t.Errorf("expected 1 source (fallback to all), got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
func TestGetInt(t *testing.T) {
|
|
if toInt(42) != 42 {
|
|
t.Error("expected 42 for int")
|
|
}
|
|
if toInt(int64(42)) != 42 {
|
|
t.Error("expected 42 for int64")
|
|
}
|
|
if toInt(float64(42.7)) != 42 {
|
|
t.Error("expected 42 for float64")
|
|
}
|
|
if toInt("42") != 42 {
|
|
t.Error("expected 42 for string")
|
|
}
|
|
if toInt("abc") != 0 {
|
|
t.Error("expected 0 for invalid string")
|
|
}
|
|
if toInt(nil) != 0 {
|
|
t.Error("expected 0 for nil")
|
|
}
|
|
}
|
|
|
|
func TestClamp(t *testing.T) {
|
|
if clamp(5, 1, 10) != 5 {
|
|
t.Error("expected 5")
|
|
}
|
|
if clamp(0, 1, 10) != 1 {
|
|
t.Error("expected 1 (min)")
|
|
}
|
|
if clamp(20, 1, 10) != 10 {
|
|
t.Error("expected 10 (max)")
|
|
}
|
|
}
|
|
|
|
func TestGetString(t *testing.T) {
|
|
if getString("hello", "default") != "hello" {
|
|
t.Error("expected hello")
|
|
}
|
|
if getString("", "default") != "default" {
|
|
t.Error("expected default")
|
|
}
|
|
if getString(nil, "default") != "default" {
|
|
t.Error("expected default for nil")
|
|
}
|
|
if getString(42, "default") != "default" {
|
|
t.Error("expected default for non-string")
|
|
}
|
|
}
|
|
|
|
func TestGetStringFromMap(t *testing.T) {
|
|
m := map[string]any{"key": "value", "num": 42}
|
|
if getStringFromMap(m, "key") != "value" {
|
|
t.Error("expected value")
|
|
}
|
|
if getStringFromMap(m, "num") != "" {
|
|
t.Error("expected empty for non-string")
|
|
}
|
|
if getStringFromMap(m, "missing") != "" {
|
|
t.Error("expected empty for missing")
|
|
}
|
|
}
|
|
|
|
func TestGetSourceUserAgent(t *testing.T) {
|
|
// From sub.Meta["ua"]
|
|
ua := getSourceUserAgent(model.SourceRecord{Meta: map[string]any{"ua": "custom-ua"}}, BuildOptions{})
|
|
if ua != "custom-ua" {
|
|
t.Errorf("expected custom-ua, got %s", ua)
|
|
}
|
|
// From sub.Meta["userAgent"]
|
|
ua = getSourceUserAgent(model.SourceRecord{Meta: map[string]any{"userAgent": "ua2"}}, BuildOptions{})
|
|
if ua != "ua2" {
|
|
t.Errorf("expected ua2, got %s", ua)
|
|
}
|
|
// From settings
|
|
ua = getSourceUserAgent(model.SourceRecord{Meta: map[string]any{}}, BuildOptions{Settings: map[string]any{"defaultUserAgent": "settings-ua"}})
|
|
if ua != "settings-ua" {
|
|
t.Errorf("expected settings-ua, got %s", ua)
|
|
}
|
|
// Default
|
|
ua = getSourceUserAgent(model.SourceRecord{Meta: map[string]any{}}, BuildOptions{})
|
|
if ua != "clash.meta/v1.19.24" {
|
|
t.Errorf("expected default ua, got %s", ua)
|
|
}
|
|
}
|
|
|
|
func TestGetCacheTtl(t *testing.T) {
|
|
// From sub.Meta
|
|
ttl := getCacheTtl(model.SourceRecord{Meta: map[string]any{"cacheTtl": 600}}, BuildOptions{})
|
|
if ttl != 600 {
|
|
t.Errorf("expected 600, got %d", ttl)
|
|
}
|
|
// From settings
|
|
ttl = getCacheTtl(model.SourceRecord{Meta: map[string]any{}}, BuildOptions{Settings: map[string]any{"remoteCacheTtl": 120}})
|
|
if ttl != 120 {
|
|
t.Errorf("expected 120, got %d", ttl)
|
|
}
|
|
// Default
|
|
ttl = getCacheTtl(model.SourceRecord{Meta: map[string]any{}}, BuildOptions{})
|
|
if ttl != 300 {
|
|
t.Errorf("expected 300, got %d", ttl)
|
|
}
|
|
// Clamp to max 3600
|
|
ttl = getCacheTtl(model.SourceRecord{Meta: map[string]any{"cacheTtl": 99999}}, BuildOptions{})
|
|
if ttl != 3600 {
|
|
t.Errorf("expected 3600 (clamped), got %d", ttl)
|
|
}
|
|
// Zero or negative -> default
|
|
ttl = getCacheTtl(model.SourceRecord{Meta: map[string]any{"cacheTtl": 0}}, BuildOptions{})
|
|
if ttl != 300 {
|
|
t.Errorf("expected 300 default, got %d", ttl)
|
|
}
|
|
}
|
|
|
|
func TestGetTimeout(t *testing.T) {
|
|
if d := getTimeout(map[string]any{"defaultTimeout": 5000}); d != 5*time.Second {
|
|
t.Errorf("expected 5s, got %v", d)
|
|
}
|
|
if d := getTimeout(map[string]any{"defaultTimeout": 100}); d != 1*time.Second {
|
|
t.Errorf("expected 1s (clamped), got %v", d)
|
|
}
|
|
if d := getTimeout(map[string]any{"defaultTimeout": 999999}); d != 120*time.Second {
|
|
t.Errorf("expected 120s (clamped), got %v", d)
|
|
}
|
|
if d := getTimeout(nil); d != 30*time.Second {
|
|
t.Errorf("expected 30s default, got %v", d)
|
|
}
|
|
}
|
|
|
|
func TestGetConcurrency(t *testing.T) {
|
|
if c := getConcurrency(map[string]any{"backendRequestConcurrency": 5}); c != 5 {
|
|
t.Errorf("expected 5, got %d", c)
|
|
}
|
|
if c := getConcurrency(map[string]any{"backendRequestConcurrency": 99}); c != 12 {
|
|
t.Errorf("expected 12 (clamped), got %d", c)
|
|
}
|
|
if c := getConcurrency(nil); c != 3 {
|
|
t.Errorf("expected 3 default, got %d", c)
|
|
}
|
|
}
|
|
|
|
func TestGetConcurrencyWait(t *testing.T) {
|
|
if w := getConcurrencyWait(map[string]any{"backendRequestConcurrencyWaitTime": 100}); w != 100*time.Millisecond {
|
|
t.Errorf("expected 100ms, got %v", w)
|
|
}
|
|
if w := getConcurrencyWait(nil); w != 0 {
|
|
t.Errorf("expected 0, got %v", w)
|
|
}
|
|
}
|
|
|
|
// --- splitSourceUrls ---
|
|
|
|
func TestSplitSourceUrls(t *testing.T) {
|
|
urls := splitSourceUrls("https://a.com\nhttp://b.com\nnot-a-url\nhttps://c.com")
|
|
if len(urls) != 3 {
|
|
t.Errorf("expected 3 urls, got %d", len(urls))
|
|
}
|
|
}
|
|
|
|
func TestSplitSourceUrls_Empty(t *testing.T) {
|
|
urls := splitSourceUrls("")
|
|
if len(urls) != 0 {
|
|
t.Errorf("expected 0 urls, got %d", len(urls))
|
|
}
|
|
}
|
|
|
|
// --- Metadata helpers ---
|
|
|
|
func TestMetadataFromSource(t *testing.T) {
|
|
meta := metadataFromSource(model.SourceRecord{
|
|
Meta: map[string]any{
|
|
"subUserinfo": "upload=1;download=2;total=3",
|
|
"profileWebPageUrl": "https://example.com",
|
|
"profileUpdateInterval": "24",
|
|
},
|
|
})
|
|
if meta.SubscriptionUserinfo != "upload=1;download=2;total=3" {
|
|
t.Errorf("expected userinfo, got %s", meta.SubscriptionUserinfo)
|
|
}
|
|
if meta.ProfileWebPageUrl != "https://example.com" {
|
|
t.Errorf("expected url, got %s", meta.ProfileWebPageUrl)
|
|
}
|
|
if meta.CacheStatus != "disabled" {
|
|
t.Errorf("expected disabled, got %s", meta.CacheStatus)
|
|
}
|
|
}
|
|
|
|
func TestMetadataFromSource_NilMeta(t *testing.T) {
|
|
meta := metadataFromSource(model.SourceRecord{})
|
|
if meta.CacheStatus != "disabled" {
|
|
t.Errorf("expected disabled, got %s", meta.CacheStatus)
|
|
}
|
|
}
|
|
|
|
func TestMetadataFromSource_AlternateKeys(t *testing.T) {
|
|
meta := metadataFromSource(model.SourceRecord{
|
|
Meta: map[string]any{
|
|
"subscriptionUserinfo": "alt-info",
|
|
"appUrl": "https://app.example.com",
|
|
},
|
|
})
|
|
if meta.SubscriptionUserinfo != "alt-info" {
|
|
t.Errorf("expected alt-info, got %s", meta.SubscriptionUserinfo)
|
|
}
|
|
if meta.ProfileWebPageUrl != "https://app.example.com" {
|
|
t.Errorf("expected app url, got %s", meta.ProfileWebPageUrl)
|
|
}
|
|
}
|
|
|
|
func TestMetadataToMap(t *testing.T) {
|
|
m := model.SubscriptionResponseMetadata{
|
|
SubscriptionUserinfo: "info",
|
|
ProfileWebPageUrl: "url",
|
|
ProfileUpdateInterval: "6",
|
|
ContentDisposition: "disp",
|
|
Etag: "etag",
|
|
LastModified: "mod",
|
|
}
|
|
result := metadataToMap(m)
|
|
if result["subscriptionUserinfo"] != "info" {
|
|
t.Error("expected info")
|
|
}
|
|
if result["etag"] != "etag" {
|
|
t.Error("expected etag")
|
|
}
|
|
}
|
|
|
|
func TestSelectResponseMetadata(t *testing.T) {
|
|
sources := []model.SourceRecord{{ID: "s1"}, {ID: "s2"}}
|
|
metaMap := map[string]model.SubscriptionResponseMetadata{
|
|
"s1": {SubscriptionUserinfo: "from-s1"},
|
|
"s2": {SubscriptionUserinfo: "from-s2"},
|
|
}
|
|
meta := selectResponseMetadata(sources, metaMap)
|
|
if meta.SubscriptionUserinfo != "from-s1" {
|
|
t.Errorf("expected from-s1, got %s", meta.SubscriptionUserinfo)
|
|
}
|
|
}
|
|
|
|
func TestSelectResponseMetadata_Empty(t *testing.T) {
|
|
meta := selectResponseMetadata([]model.SourceRecord{}, map[string]model.SubscriptionResponseMetadata{})
|
|
if meta.SubscriptionUserinfo != "" {
|
|
t.Error("expected empty")
|
|
}
|
|
}
|
|
|
|
// --- Concurrency ---
|
|
|
|
func TestRunWithConcurrency(t *testing.T) {
|
|
tasks := make([]func() (int, error), 10)
|
|
for i := range tasks {
|
|
i := i
|
|
tasks[i] = func() (int, error) { return i, nil }
|
|
}
|
|
results, err := RunWithConcurrency(tasks, 3, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(results) != 10 {
|
|
t.Fatalf("expected 10 results, got %d", len(results))
|
|
}
|
|
for i, v := range results {
|
|
if v != i {
|
|
t.Errorf("expected %d, got %d", i, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWithConcurrency_WithErrors(t *testing.T) {
|
|
taskErr := errors.New("task error")
|
|
tasks := make([]func() (int, error), 5)
|
|
for i := range tasks {
|
|
i := i
|
|
if i == 2 {
|
|
tasks[i] = func() (int, error) { return 0, taskErr }
|
|
} else {
|
|
tasks[i] = func() (int, error) { return i, nil }
|
|
}
|
|
}
|
|
results, err := RunWithConcurrency(tasks, 2, 0)
|
|
if !errors.Is(err, taskErr) {
|
|
t.Fatalf("error = %v, want task error", err)
|
|
}
|
|
if len(results) != 5 {
|
|
t.Fatalf("expected 5 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestRunWithConcurrency_Empty(t *testing.T) {
|
|
results, err := RunWithConcurrency([]func() (int, error){}, 3, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestRunWithConcurrency_ConcurrencyOne(t *testing.T) {
|
|
var counter int32
|
|
tasks := make([]func() (int, error), 5)
|
|
for i := range tasks {
|
|
i := i
|
|
tasks[i] = func() (int, error) {
|
|
atomic.AddInt32(&counter, 1)
|
|
return i, nil
|
|
}
|
|
}
|
|
results, _ := RunWithConcurrency(tasks, 1, 0)
|
|
if len(results) != 5 {
|
|
t.Errorf("expected 5 results, got %d", len(results))
|
|
}
|
|
if atomic.LoadInt32(&counter) != 5 {
|
|
t.Errorf("expected counter 5, got %d", counter)
|
|
}
|
|
}
|
|
|
|
func TestRunSettledWithConcurrency(t *testing.T) {
|
|
tasks := make([]func() (int, error), 5)
|
|
for i := range tasks {
|
|
i := i
|
|
if i == 2 {
|
|
tasks[i] = func() (int, error) { return 0, errors.New("rejected") }
|
|
} else {
|
|
tasks[i] = func() (int, error) { return i, nil }
|
|
}
|
|
}
|
|
results := RunSettledWithConcurrency(tasks, 2, 0)
|
|
if len(results) != 5 {
|
|
t.Fatalf("expected 5 results, got %d", len(results))
|
|
}
|
|
if results[2].Status != "rejected" {
|
|
t.Errorf("expected rejected at index 2, got %s", results[2].Status)
|
|
}
|
|
fulfilled := 0
|
|
for _, r := range results {
|
|
if r.Status == "fulfilled" {
|
|
fulfilled++
|
|
}
|
|
}
|
|
if fulfilled != 4 {
|
|
t.Errorf("expected 4 fulfilled, got %d", fulfilled)
|
|
}
|
|
}
|
|
|
|
func TestRunSettledWithConcurrency_Empty(t *testing.T) {
|
|
results := RunSettledWithConcurrency([]func() (int, error){}, 3, 0)
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
// --- AsyncWriter ---
|
|
|
|
func TestAsyncWriter_WriteAndWait(t *testing.T) {
|
|
aw := NewAsyncWriter()
|
|
var counter int32
|
|
for i := 0; i < 5; i++ {
|
|
aw.Write(func() {
|
|
atomic.AddInt32(&counter, 1)
|
|
})
|
|
}
|
|
// Give goroutines time to execute before Wait cancels the context
|
|
time.Sleep(100 * time.Millisecond)
|
|
aw.Wait()
|
|
if atomic.LoadInt32(&counter) != 5 {
|
|
t.Errorf("expected counter 5, got %d", counter)
|
|
}
|
|
}
|
|
|
|
func TestAsyncWriter_WaitWithTimeout(t *testing.T) {
|
|
aw := NewAsyncWriter()
|
|
var counter int32
|
|
for i := 0; i < 5; i++ {
|
|
aw.Write(func() {
|
|
atomic.AddInt32(&counter, 1)
|
|
})
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
aw.WaitWithTimeout(5 * time.Second)
|
|
if atomic.LoadInt32(&counter) != 5 {
|
|
t.Errorf("expected counter 5, got %d", counter)
|
|
}
|
|
}
|
|
|
|
func TestAsyncWriter_WaitWithTimeout_TimedOut(t *testing.T) {
|
|
aw := NewAsyncWriter()
|
|
// Write a task that sleeps longer than timeout
|
|
aw.Write(func() {
|
|
time.Sleep(2 * time.Second)
|
|
})
|
|
// WaitWithTimeout should return without hanging
|
|
aw.WaitWithTimeout(50 * time.Millisecond)
|
|
// Test passes if it doesn't hang
|
|
}
|
|
|
|
// --- loadSubscriptionRaw ---
|
|
|
|
func TestLoadSubscriptionRaw_LocalSource(t *testing.T) {
|
|
sub := model.SourceRecord{
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1",
|
|
}
|
|
raw, meta, err := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if raw != "ss://pass@host:80#Node1" {
|
|
t.Errorf("expected content, got %s", raw)
|
|
}
|
|
if meta.CacheStatus != "disabled" {
|
|
t.Errorf("expected disabled, got %s", meta.CacheStatus)
|
|
}
|
|
}
|
|
|
|
func TestLoadSubscriptionRaw_RemoteNoUrls(t *testing.T) {
|
|
sub := model.SourceRecord{
|
|
Type: "remote",
|
|
URL: "",
|
|
}
|
|
raw, _, err := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if raw != "" {
|
|
t.Errorf("expected empty, got %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestLoadSubscriptionRaw_ContentPresent(t *testing.T) {
|
|
// Content present takes priority even for remote
|
|
sub := model.SourceRecord{
|
|
Type: "remote",
|
|
Content: "ss://pass@host:80#Node1",
|
|
URL: "https://example.com/sub",
|
|
}
|
|
raw, _, err := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if raw != "ss://pass@host:80#Node1" {
|
|
t.Errorf("expected content only, got %s", raw)
|
|
}
|
|
}
|
|
|
|
// --- Render via BuildSubscriptionResult for various targets ---
|
|
|
|
func TestBuildSubscriptionResult_URITarget(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "uri",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Body == "" {
|
|
t.Error("expected non-empty body")
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_V2rayTarget(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "v2ray",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Body == "" {
|
|
t.Error("expected non-empty body")
|
|
}
|
|
}
|
|
|
|
func TestBuildSubscriptionResult_SingBoxTarget(t *testing.T) {
|
|
source := &model.SourceRecord{
|
|
ID: "test-src",
|
|
Name: "Test",
|
|
Type: "local",
|
|
Content: "ss://pass@host:80#Node1",
|
|
Enabled: true,
|
|
Filters: []model.FilterRule{},
|
|
Meta: map[string]any{},
|
|
}
|
|
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
|
|
Source: source,
|
|
Sources: []model.SourceRecord{*source},
|
|
Target: "sing-box",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if result.Body == "" {
|
|
t.Error("expected non-empty body")
|
|
}
|
|
}
|