Files
rogee 9a4e60cf10
Build and Publish Docker Image / build-and-push (push) Failing after 9m58s
HH-683: propagate subscription fetch failures (#1)
2026-08-26 14:18:53 +08:00

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