import { describe, expect, it } from 'bun:test' import { DEFAULT_HEALTH_CHECK, DEFAULT_INTERVAL, addFileSubscription, addSubscription, listSubscriptions, moveSubscription, 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('调整订阅顺序', () => { /** 三个用户订阅,外加一份接在中间的订阅副本。 */ function three(): ConfigObject { let data: ConfigObject = {} for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`) return data } it('上移和下移交换相邻两个订阅', () => { expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([ 'B', 'A', 'C', ]) expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([ 'A', 'C', 'B', ]) }) it('到头了原样返回,配置对象不变', () => { const data = three() expect(moveSubscription(data, 'A', -1)).toBe(data) expect(moveSubscription(data, 'C', 1)).toBe(data) expect(moveSubscription(data, '不存在', 1)).toBe(data) }) it('夹在中间的订阅副本留在原地', () => { const withCopy = applySetup( { ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] }, 'test1', { proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } }, ) const copy = chainProviderName('test1', 'B') const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy) const after = moveSubscription(withCopy, 'A', 1) expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C']) expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before) }) }) 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() }) })