内核没有默认的健康检查地址,url 为空时检查根本不会运行 (adapter/provider/healthcheck.go:196-199),所以默认值必须写全; 开关只翻 enable,手写的 url/interval 留着下次开启时还用。 改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存 链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型 的数据本体和副本认亲都靠它。
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
import {
|
|
DEFAULT_HEALTH_CHECK,
|
|
DEFAULT_INTERVAL,
|
|
addFileSubscription,
|
|
addSubscription,
|
|
listSubscriptions,
|
|
providerNames,
|
|
removeSubscription,
|
|
renameSubscription,
|
|
setSubscriptionHealthCheck,
|
|
subscriptionPathConflict,
|
|
subscriptionReferences,
|
|
updateSubscription,
|
|
} from './providers.ts'
|
|
import {
|
|
applySetup,
|
|
chainProviderName,
|
|
providerCachePath,
|
|
pruneGenerated,
|
|
syncGenerated,
|
|
} from './inbound-groups.ts'
|
|
import type { OutboundSetup } from './inbound-groups.ts'
|
|
import { collectInbounds } from './inbounds.ts'
|
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
|
|
|
const URL = 'http://127.0.0.1:3001/token/download/V2House'
|
|
|
|
/** 一份带一个订阅的配置,形状仿照目标机器上那份。 */
|
|
function base(): ConfigObject {
|
|
return {
|
|
'proxy-providers': {
|
|
subs: {
|
|
type: 'http',
|
|
url: URL,
|
|
path: './providers/subs.yaml',
|
|
interval: 86400,
|
|
'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
|
},
|
|
},
|
|
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
|
|
}
|
|
}
|
|
|
|
const chainedSetup: OutboundSetup = {
|
|
proxy: { sources: ['subs'], filter: null },
|
|
chain: { sources: ['subs'], filter: null },
|
|
}
|
|
|
|
describe('读出订阅列表', () => {
|
|
it('读出地址、间隔和健康检查', () => {
|
|
expect(listSubscriptions(base())).toEqual([
|
|
{
|
|
name: 'subs',
|
|
type: 'http',
|
|
path: './providers/subs.yaml',
|
|
url: URL,
|
|
interval: 86400,
|
|
healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
|
},
|
|
])
|
|
})
|
|
|
|
it('健康检查没写或没开都算未开启', () => {
|
|
const data = base()
|
|
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
delete subs['health-check']
|
|
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
|
|
subs['health-check'] = { enable: false, url: 'https://example.com', interval: 60 }
|
|
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
|
|
})
|
|
|
|
it('不含 Patchbay 生成的订阅副本', () => {
|
|
const data = applySetup(
|
|
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
|
'test1',
|
|
chainedSetup,
|
|
)
|
|
expect(listSubscriptions(data).map((s) => s.name)).toEqual(['subs'])
|
|
// 副本还在,只是不列出来
|
|
expect(providerNames(data)).toContain(chainProviderName('test1', 'subs'))
|
|
})
|
|
|
|
it('没有 proxy-providers 段时是空列表', () => {
|
|
expect(listSubscriptions({ mode: 'rule' })).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('新增订阅', () => {
|
|
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
|
|
const next = addSubscription({}, 'subs2', URL)
|
|
expect(next['proxy-providers']).toEqual({
|
|
subs2: {
|
|
type: 'http',
|
|
url: URL,
|
|
path: './providers/subs2.yaml',
|
|
interval: DEFAULT_INTERVAL,
|
|
'health-check': DEFAULT_HEALTH_CHECK,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('不动已有的订阅', () => {
|
|
const data = base()
|
|
const next = addSubscription(data, 'subs2', URL)
|
|
expect((next['proxy-providers'] as ConfigObject).subs).toEqual(
|
|
(data['proxy-providers'] as ConfigObject).subs,
|
|
)
|
|
})
|
|
|
|
it('关掉自动更新时不写 interval 键', () => {
|
|
const next = addSubscription({}, 'subs2', URL, null)
|
|
const entry = (next['proxy-providers'] as ConfigObject).subs2 as ConfigObject
|
|
expect('interval' in entry).toBe(false)
|
|
})
|
|
|
|
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
|
|
const next = addFileSubscription({}, 'subs2')
|
|
expect(next['proxy-providers']).toEqual({
|
|
subs2: {
|
|
type: 'file',
|
|
path: './providers/subs2.yaml',
|
|
'health-check': DEFAULT_HEALTH_CHECK,
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('健康检查开关', () => {
|
|
it('开启时缺的字段用默认值补齐', () => {
|
|
const data = base()
|
|
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
delete subs['health-check']
|
|
const next = setSubscriptionHealthCheck(data, 'subs', true)
|
|
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
expect(entry['health-check']).toEqual(DEFAULT_HEALTH_CHECK)
|
|
})
|
|
|
|
it('关闭只翻 enable,手写的 url 和 interval 留着', () => {
|
|
const data = base()
|
|
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
subs['health-check'] = { enable: true, url: 'https://example.com/ping', interval: 60 }
|
|
const next = setSubscriptionHealthCheck(data, 'subs', false)
|
|
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
expect(entry['health-check']).toEqual({
|
|
enable: false,
|
|
url: 'https://example.com/ping',
|
|
interval: 60,
|
|
})
|
|
// 再开启时它们原样回来
|
|
const back = setSubscriptionHealthCheck(next, 'subs', true)
|
|
expect(listSubscriptions(back)[0]?.healthCheck).toEqual({
|
|
url: 'https://example.com/ping',
|
|
interval: 60,
|
|
})
|
|
})
|
|
|
|
it('本来就没写又要关闭时不做改动', () => {
|
|
const data = base()
|
|
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
delete subs['health-check']
|
|
expect(setSubscriptionHealthCheck(data, 'subs', false)).toBe(data)
|
|
})
|
|
|
|
it('订阅不存在时不做改动', () => {
|
|
const data = base()
|
|
expect(setSubscriptionHealthCheck(data, 'ghost', true)).toBe(data)
|
|
})
|
|
})
|
|
|
|
describe('订阅改名', () => {
|
|
it('改映射键并同步策略组的 use 引用,path 不动', () => {
|
|
const next = renameSubscription(base(), 'subs', 'subs-new')
|
|
const providers = next['proxy-providers'] as ConfigObject
|
|
expect(Object.keys(providers)).toEqual(['subs-new'])
|
|
expect((providers['subs-new'] as ConfigObject).path).toBe('./providers/subs.yaml')
|
|
const group = (next['proxy-groups'] as ConfigObject[])[0]!
|
|
expect(group.use).toEqual(['subs-new'])
|
|
})
|
|
|
|
it('保持条目在映射里的位置', () => {
|
|
const data = addSubscription(addSubscription(base(), 'a', URL), 'b', URL)
|
|
const next = renameSubscription(data, 'a', 'z')
|
|
expect(Object.keys(next['proxy-providers'] as ConfigObject)).toEqual(['subs', 'z', 'b'])
|
|
})
|
|
|
|
it('改名后保存链路按新名字重建链式副本', () => {
|
|
const data = applySetup(
|
|
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
|
'test1',
|
|
chainedSetup,
|
|
)
|
|
const renamed = renameSubscription(data, 'subs', 'subs2')
|
|
// 页面 commit() 每次落盘前都跑这两步
|
|
const synced = pruneGenerated(syncGenerated(renamed, collectInbounds(renamed)))
|
|
expect(providerNames(synced)).toContain(chainProviderName('test1', 'subs2'))
|
|
expect(providerNames(synced)).not.toContain(chainProviderName('test1', 'subs'))
|
|
// 引用检查也要认得出新名字,删除保护不能因为改名失效
|
|
expect(subscriptionReferences(synced, 'subs2')).not.toEqual([])
|
|
})
|
|
|
|
it('原名不存在、新名已占用或没改时都不做改动', () => {
|
|
const data = base()
|
|
expect(renameSubscription(data, 'ghost', 'x')).toBe(data)
|
|
const withX = addSubscription(data, 'x', URL)
|
|
expect(renameSubscription(withX, 'subs', 'x')).toBe(withX)
|
|
expect(renameSubscription(data, 'subs', 'subs')).toBe(data)
|
|
})
|
|
})
|
|
|
|
describe('编辑订阅', () => {
|
|
it('只改地址和间隔,其余字段原样保留', () => {
|
|
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
|
|
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
expect(entry.url).toBe('http://example.com/new')
|
|
expect(entry.interval).toBe(3600)
|
|
expect(entry.path).toBe('./providers/subs.yaml')
|
|
expect(entry['health-check']).toEqual({
|
|
enable: true,
|
|
url: 'https://www.gstatic.com/generate_204',
|
|
interval: 300,
|
|
})
|
|
})
|
|
|
|
it('订阅不存在时不做改动', () => {
|
|
const data = base()
|
|
expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data)
|
|
})
|
|
|
|
it('关掉自动更新时把已有的 interval 键去掉', () => {
|
|
const next = updateSubscription(base(), 'subs', URL, null)
|
|
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
|
expect('interval' in entry).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('删除订阅', () => {
|
|
it('从映射里去掉', () => {
|
|
const data = addSubscription(base(), 'subs2', URL)
|
|
const next = removeSubscription(data, 'subs2')
|
|
expect(providerNames(next)).toEqual(['subs'])
|
|
})
|
|
|
|
it('删到最后一个时整个键去掉,不留空映射', () => {
|
|
const next = removeSubscription(base(), 'subs')
|
|
expect('proxy-providers' in next).toBe(false)
|
|
})
|
|
|
|
it('订阅不存在时不做改动', () => {
|
|
const data = base()
|
|
expect(removeSubscription(data, 'ghost')).toBe(data)
|
|
})
|
|
})
|
|
|
|
describe('删除前的引用检查', () => {
|
|
it('找出 use 它的策略组', () => {
|
|
expect(subscriptionReferences(base(), 'subs')).toEqual(['PROXY'])
|
|
})
|
|
|
|
it('没人在用时是空列表', () => {
|
|
const data = base()
|
|
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
|
|
expect(subscriptionReferences(data, 'subs')).toEqual([])
|
|
})
|
|
|
|
it('链式代理的专属组 use 的是副本,也算引用原订阅', () => {
|
|
const data = applySetup(
|
|
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
|
'test1',
|
|
chainedSetup,
|
|
)
|
|
const refs = subscriptionReferences(data, 'subs')
|
|
// 入口组直接 use 原订阅,三个专属组 use 副本,两边都要认出来
|
|
expect(refs).toContain('patchbay:test1 链式')
|
|
expect(refs).toContain('patchbay:test1')
|
|
})
|
|
|
|
it('订阅不存在时是空列表', () => {
|
|
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('缓存路径撞名', () => {
|
|
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
|
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
|
const path = providerCachePath('订阅/A')
|
|
expect(path).toBe(providerCachePath('订阅 A'))
|
|
expect(subscriptionPathConflict(data, '订阅/A', path)).toBe('订阅 A')
|
|
})
|
|
|
|
it('不同路径、或者就是自己,都不算撞', () => {
|
|
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
|
expect(subscriptionPathConflict(data, 'B', providerCachePath('B'))).toBeNull()
|
|
expect(subscriptionPathConflict(data, '订阅 A', providerCachePath('订阅 A'))).toBeNull()
|
|
})
|
|
})
|