| const test = require('node:test'); |
| const assert = require('node:assert/strict'); |
| const fs = require('node:fs'); |
| const path = require('node:path'); |
| const vm = require('node:vm'); |
|
|
| const html = fs.readFileSync(path.join(__dirname, '..', '..', 'channels.html'), 'utf8'); |
| const protocolSource = fs.readFileSync(path.join(__dirname, 'channels-protocols.js'), 'utf8'); |
| const source = fs.readFileSync(path.join(__dirname, 'channels-modals.js'), 'utf8'); |
|
|
| const CHANNEL_TYPES = ['anthropic', 'codex', 'openai', 'gemini']; |
| const KEY_STRATEGIES = ['sequential', 'round_robin']; |
|
|
| function createClassList() { |
| const names = new Set(); |
| return { |
| add(...tokens) { |
| tokens.filter(Boolean).forEach((token) => names.add(token)); |
| }, |
| remove(...tokens) { |
| tokens.forEach((token) => names.delete(token)); |
| }, |
| contains(token) { |
| return names.has(token); |
| } |
| }; |
| } |
|
|
| function createElement(props = {}) { |
| const listeners = new Map(); |
| const attributes = new Map(); |
|
|
| const element = { |
| id: props.id || '', |
| name: props.name || '', |
| type: props.type || '', |
| value: props.value || '', |
| checked: !!props.checked, |
| disabled: !!props.disabled, |
| hidden: !!props.hidden, |
| dataset: props.dataset || {}, |
| style: props.style || {}, |
| textContent: props.textContent || '', |
| children: [], |
| classList: createClassList(), |
| appendChild(child) { |
| this.children.push(child); |
| return child; |
| }, |
| addEventListener(type, handler) { |
| const current = listeners.get(type) || []; |
| current.push(handler); |
| listeners.set(type, current); |
| }, |
| async dispatchEvent(event) { |
| const nextEvent = event || {}; |
| const type = nextEvent.type; |
| if (!type) return; |
| nextEvent.target = nextEvent.target || element; |
| nextEvent.currentTarget = element; |
| if (typeof nextEvent.preventDefault !== 'function') { |
| nextEvent.preventDefault = () => {}; |
| } |
| const handlers = listeners.get(type) || []; |
| for (const handler of handlers) { |
| await handler(nextEvent); |
| } |
| }, |
| setAttribute(name, value) { |
| attributes.set(name, String(value)); |
| }, |
| getAttribute(name) { |
| return attributes.get(name); |
| }, |
| reset() {}, |
| focus() {} |
| }; |
|
|
| return element; |
| } |
|
|
| function createDeferred() { |
| let resolve; |
| let reject; |
| const promise = new Promise((resolvePromise, rejectPromise) => { |
| resolve = resolvePromise; |
| reject = rejectPromise; |
| }); |
| return { promise, resolve, reject }; |
| } |
|
|
| function createHarness({ |
| channel = null, |
| apiKeys = [{ api_key: 'sk-test' }], |
| channelCheckIntervalHours = 24, |
| channelCheckIntervalResponse = null, |
| apiKeysResponse = null, |
| saveResponse = null, |
| duplicateResponses = null |
| } = {}) { |
| let protocolTransformInputs = []; |
| let protocolTransformModeInputs = []; |
| const elements = {}; |
| const radiosByName = new Map(); |
| const fetchCalls = []; |
| const dataFetchCalls = []; |
| const duplicateResponseQueue = Array.isArray(duplicateResponses) ? duplicateResponses.slice() : null; |
| let afterSavePayload = null; |
| let nextTimerId = 1; |
| const timers = new Map(); |
|
|
| function registerRadio(name, value, checked = false) { |
| const radio = createElement({ name, value, type: 'radio', checked }); |
| if (!radiosByName.has(name)) { |
| radiosByName.set(name, []); |
| } |
| radiosByName.get(name).push(radio); |
| return radio; |
| } |
|
|
| function setCheckedRadio(name, value) { |
| for (const radio of radiosByName.get(name) || []) { |
| radio.checked = radio.value === value; |
| } |
| } |
|
|
| function getCheckedRadio(name) { |
| return (radiosByName.get(name) || []).find((radio) => radio.checked) || null; |
| } |
|
|
| function getRadio(name, value) { |
| return (radiosByName.get(name) || []).find((radio) => radio.value === value) || null; |
| } |
|
|
| function queryInputs(selector) { |
| const match = selector.match(/^input\[name="([^"]+)"\](?:\[value="([^"]+)"\])?(?::checked)?$/); |
| if (!match) return []; |
|
|
| const [, name, value] = match; |
| const checkedOnly = selector.endsWith(':checked'); |
| const pool = name === 'protocolTransform' |
| ? protocolTransformInputs |
| : name === 'protocolTransformMode' |
| ? protocolTransformModeInputs |
| : radiosByName.get(name) || []; |
|
|
| return pool.filter((input) => { |
| if (value && input.value !== value) return false; |
| if (checkedOnly && !input.checked) return false; |
| return true; |
| }); |
| } |
|
|
| function parseProtocolTransformInputs(markup) { |
| const inputs = []; |
| const regex = /<input type="checkbox"[\s\S]*?name="protocolTransform"[\s\S]*?value="([^"]+)"([\s\S]*?)>/g; |
| let match; |
| while ((match = regex.exec(markup))) { |
| inputs.push(createElement({ |
| name: 'protocolTransform', |
| type: 'checkbox', |
| value: match[1], |
| checked: /\bchecked\b/.test(match[2]), |
| disabled: /\bdisabled\b/.test(match[2]) |
| })); |
| } |
| protocolTransformInputs = inputs; |
| } |
|
|
| function parseProtocolTransformModeInputs(markup) { |
| const inputs = []; |
| const regex = /<input type="radio"[\s\S]*?name="protocolTransformMode"[\s\S]*?value="([^"]+)"([\s\S]*?)>/g; |
| let match; |
| while ((match = regex.exec(markup))) { |
| inputs.push(createElement({ |
| name: 'protocolTransformMode', |
| type: 'radio', |
| value: match[1], |
| checked: /\bchecked\b/.test(match[2]), |
| disabled: /\bdisabled\b/.test(match[2]) |
| })); |
| } |
| protocolTransformModeInputs = inputs; |
| } |
|
|
| elements.protocolTransformsContainer = createElement({ id: 'protocolTransformsContainer' }); |
| Object.defineProperty(elements.protocolTransformsContainer, 'innerHTML', { |
| get() { |
| return this._innerHTML || ''; |
| }, |
| set(value) { |
| this._innerHTML = value; |
| parseProtocolTransformInputs(String(value || '')); |
| } |
| }); |
| elements.protocolTransformModeContainer = createElement({ id: 'protocolTransformModeContainer' }); |
| Object.defineProperty(elements.protocolTransformModeContainer, 'innerHTML', { |
| get() { |
| return this._innerHTML || ''; |
| }, |
| set(value) { |
| this._innerHTML = value; |
| parseProtocolTransformModeInputs(String(value || '')); |
| } |
| }); |
|
|
| elements.channelTypeRadios = createElement({ id: 'channelTypeRadios', dataset: {} }); |
| elements.channelForm = createElement({ id: 'channelForm', dataset: {} }); |
| elements.channelName = createElement({ id: 'channelName', value: channel ? channel.name : '协议转换渠道' }); |
| elements.channelUrl = createElement({ id: 'channelUrl', value: '' }); |
| elements.channelApiKey = createElement({ id: 'channelApiKey', value: '' }); |
| elements.channelPriority = createElement({ id: 'channelPriority', value: channel ? String(channel.priority || 0) : '0' }); |
| elements.channelRPMLimit = createElement({ id: 'channelRPMLimit', value: channel ? String(channel.rpm_limit || 0) : '0' }); |
| elements.channelDailyCostLimit = createElement({ id: 'channelDailyCostLimit', value: channel ? String(channel.daily_cost_limit || 0) : '0' }); |
| elements.channelCostMultiplier = createElement({ id: 'channelCostMultiplier', value: channel && channel.cost_multiplier ? String(channel.cost_multiplier) : '1' }); |
| elements.channelEnabled = createElement({ id: 'channelEnabled', type: 'checkbox', checked: channel ? channel.enabled !== false : true }); |
| elements.channelScheduledCheckEnabled = createElement({ id: 'channelScheduledCheckEnabled', type: 'checkbox', checked: !!(channel && channel.scheduled_check_enabled), dataset: {} }); |
| elements.channelScheduledCheckModel = createElement({ id: 'channelScheduledCheckModel', value: channel ? channel.scheduled_check_model || '' : '' }); |
| elements.channelScheduledCheckModelInput = createElement({ id: 'channelScheduledCheckModelInput', value: '' }); |
| elements.channelScheduledCheckModelDropdown = createElement({ id: 'channelScheduledCheckModelDropdown', dataset: {}, style: {} }); |
| elements.channelScheduledCheckModelWrapper = createElement({ id: 'channelScheduledCheckModelWrapper', hidden: false }); |
| elements.channelScheduledCheckEnabledWrapper = createElement({ id: 'channelScheduledCheckEnabledWrapper', hidden: false }); |
| elements.channelScheduledCheckModelHint = createElement({ id: 'channelScheduledCheckModelHint', textContent: '' }); |
| elements.channelDuplicateHint = createElement({ id: 'channelDuplicateHint', hidden: true, textContent: '' }); |
| elements.channelModal = createElement({ id: 'channelModal' }); |
| elements.channelSaveBtn = createElement({ id: 'channelSaveBtn', disabled: false }); |
| elements.inlineEyeIcon = createElement({ id: 'inlineEyeIcon', style: {} }); |
| elements.inlineEyeOffIcon = createElement({ id: 'inlineEyeOffIcon', style: {} }); |
| elements.modelFilterInput = createElement({ id: 'modelFilterInput', value: '' }); |
| elements.redirectCount = createElement({ id: 'redirectCount', textContent: '0' }); |
| elements.redirectTableBody = createElement({ id: 'redirectTableBody' }); |
| elements.selectAllModels = createElement({ id: 'selectAllModels', type: 'checkbox', checked: false }); |
|
|
| CHANNEL_TYPES.forEach((type, index) => { |
| registerRadio('channelType', type, index === 0); |
| }); |
| KEY_STRATEGIES.forEach((type, index) => { |
| registerRadio('keyStrategy', type, index === 0); |
| }); |
|
|
| const sandbox = { |
| console, |
| alert() {}, |
| confirm() { return true; }, |
| editingChannelId: null, |
| channelFormDirty: false, |
| channels: channel ? [channel] : [], |
| currentChannelKeyCooldowns: [], |
| redirectTableData: channel && Array.isArray(channel.models) |
| ? channel.models.map((model) => ({ model: model.model || '', redirect_model: model.redirect_model || '' })) |
| : [{ model: 'claude-3-7-sonnet', redirect_model: '' }], |
| selectedModelIndices: new Set(), |
| selectedURLIndices: new Set(), |
| inlineURLTableData: channel ? String(channel.url || '').split('\n').filter(Boolean) : ['https://api.example.com'], |
| inlineKeyTableData: apiKeys.map((key) => key.api_key || key), |
| inlineKeyVisible: true, |
| currentModelFilter: '', |
| deletingChannelRequest: null, |
| selectedChannelIds: new Set(), |
| filters: { channelType: 'all' }, |
| resetChannelFormDirty() { |
| sandbox.channelFormDirty = false; |
| }, |
| markChannelFormDirty() { |
| sandbox.channelFormDirty = true; |
| }, |
| renderInlineKeyTable() {}, |
| renderInlineURLTable() {}, |
| renderRedirectTable() {}, |
| fetchURLStats() {}, |
| clearChannelsCache() {}, |
| loadChannels: async () => {}, |
| saveChannelsFilters() {}, |
| normalizeSelectedChannelID(value) { return String(value); }, |
| setInlineURLTableData(value) { |
| sandbox.inlineURLTableData = String(value || '').split('\n').filter(Boolean); |
| }, |
| getValidInlineURLs() { |
| return sandbox.inlineURLTableData.filter((url) => url && url.trim()); |
| }, |
| createSearchableCombobox(config) { |
| return { |
| setValue(value, label) { |
| elements.channelScheduledCheckModel.value = value; |
| elements.channelScheduledCheckModelInput.value = label; |
| }, |
| refresh() {}, |
| getInput() { |
| return elements.channelScheduledCheckModelInput; |
| } |
| }; |
| }, |
| fetchDataWithAuth: async (requestPath) => { |
| dataFetchCalls.push(requestPath); |
| if (requestPath === '/admin/settings/channel_check_interval_hours') { |
| if (channelCheckIntervalResponse) { |
| return await channelCheckIntervalResponse; |
| } |
| return { value: channelCheckIntervalHours }; |
| } |
| if (channel && requestPath === `/admin/channels/${channel.id}/keys`) { |
| if (apiKeysResponse) { |
| return await apiKeysResponse; |
| } |
| return apiKeys; |
| } |
| throw new Error(`unexpected fetchDataWithAuth: ${requestPath}`); |
| }, |
| fetchAPIWithAuth: async (requestPath, options) => { |
| fetchCalls.push({ path: requestPath, options }); |
| if (requestPath === '/admin/channels/check-duplicate' && duplicateResponseQueue && duplicateResponseQueue.length > 0) { |
| const nextResponse = duplicateResponseQueue.shift(); |
| return typeof nextResponse === 'function' ? nextResponse(requestPath, options) : nextResponse; |
| } |
| if ((requestPath === '/admin/channels' || requestPath === `/admin/channels/${channel?.id}`) && saveResponse) { |
| return await saveResponse; |
| } |
| return { success: true }; |
| }, |
| setTimeout(callback) { |
| const timerId = nextTimerId++; |
| timers.set(timerId, callback); |
| return timerId; |
| }, |
| clearTimeout(timerId) { |
| timers.delete(timerId); |
| }, |
| document: { |
| body: {}, |
| createDocumentFragment() { |
| return { |
| children: [], |
| appendChild(child) { |
| this.children.push(child); |
| } |
| }; |
| }, |
| getElementById(id) { |
| return elements[id] || null; |
| }, |
| querySelector(selector) { |
| return queryInputs(selector)[0] || null; |
| }, |
| querySelectorAll(selector) { |
| return queryInputs(selector); |
| } |
| }, |
| window: { |
| t(key) { |
| const labels = { |
| 'channels.protocolTransformAnthropic': 'Claude Code', |
| 'channels.protocolBadgeAnthropic': 'Claude', |
| 'channels.protocolTransformCodex': 'Codex', |
| 'channels.protocolTransformOpenAI': 'OpenAI', |
| 'channels.protocolTransformGemini': 'Gemini', |
| 'channels.protocolTransformNative': '原生', |
| 'channels.protocolTransformModeLocal': 'ccLoad(实验性)', |
| 'channels.protocolTransformModeUpstream': '上游', |
| 'channels.duplicateModelsNotAllowed': 'duplicate models', |
| 'channels.duplicateChannelHint': 'duplicate channels: {list}{extra}', |
| 'channels.duplicateChannelHintMore': ' and {count} more', |
| 'channels.duplicateChannelHintSeparator': ', ', |
| 'channels.fillAllRequired': 'fill required', |
| 'channels.channelAdded': 'added', |
| 'channels.channelUpdated': 'updated', |
| 'channels.copySuffix': 'Copy', |
| 'channels.scheduledCheckModelDefault': '默认首个模型', |
| 'channels.scheduledCheckModelHint': '仅用于定时检测,留空表示默认首个模型', |
| 'channels.scheduledCheckModelFallback': '当前检测模型已失效,已回退为默认首个模型', |
| 'channels.saveFailed': 'save failed', |
| 'channels.fetchModelsSuccess': 'fetch models success', |
| 'channels.fetchModelsFailed': 'fetch models failed', |
| 'channels.addedCommonModels': 'added common models', |
| 'channels.noPresetModels': 'no preset models', |
| 'channels.unsavedChanges': 'unsaved changes' |
| }; |
| let text = labels[key] || key; |
| const params = arguments[1] || {}; |
| Object.entries(params).forEach(([paramKey, value]) => { |
| text = text.replaceAll(`{${paramKey}}`, String(value)); |
| }); |
| return text; |
| }, |
| initDelegatedActions() {}, |
| ChannelTypeManager: { |
| async renderChannelTypeRadios(_containerId, currentType) { |
| setCheckedRadio('channelType', currentType || 'anthropic'); |
| } |
| }, |
| ChannelModalHooks: { |
| async afterSave(payload) { |
| afterSavePayload = payload; |
| } |
| }, |
| TemplateEngine: { |
| render(_templateId, data = {}) { |
| return { |
| dataset: { model: data.model || '', redirectModel: data.redirect_model || '' }, |
| querySelector() { return null; } |
| }; |
| } |
| }, |
| showSuccess() {}, |
| showError(message) { |
| throw new Error(message); |
| }, |
| i18nText(key, fallback) { |
| const result = sandbox.window.t(key); |
| return result && result !== key ? result : fallback; |
| } |
| }, |
| TemplateEngine: { |
| render(_templateId, data = {}) { |
| return { |
| dataset: { model: data.model || '', redirectModel: data.redirect_model || '' }, |
| querySelector() { return null; } |
| }; |
| } |
| } |
| }; |
|
|
| vm.createContext(sandbox); |
| vm.runInContext(`${protocolSource}\n${source}\nthis.__protocolTransformsTest = {\n initChannelEditorActions,\n renderProtocolTransformOptions,\n renderProtocolTransformModeOptions,\n getSelectedProtocolTransforms,\n getSelectedProtocolTransformMode,\n syncProtocolTransformModeForURLs,\n editChannel,\n copyChannel,\n saveChannel,\n refreshChannelDuplicateHint,\n scheduleChannelDuplicateHintCheck\n};`, sandbox); |
|
|
| return { |
| api: sandbox.__protocolTransformsTest, |
| elements, |
| fetchCalls, |
| dataFetchCalls, |
| getAfterSavePayload: () => afterSavePayload, |
| getProtocolTransformInput(value) { |
| return protocolTransformInputs.find((input) => input.value === value) || null; |
| }, |
| getProtocolTransformValues() { |
| return protocolTransformInputs.map((input) => ({ |
| value: input.value, |
| checked: input.checked, |
| disabled: input.disabled |
| })); |
| }, |
| getProtocolTransformModeInput(value) { |
| return protocolTransformModeInputs.find((input) => input.value === value) || null; |
| }, |
| getRadio, |
| setCheckedRadio, |
| setEditingChannelId(value) { |
| sandbox.editingChannelId = value; |
| }, |
| getCurrentKeyCooldowns() { |
| return sandbox.currentChannelKeyCooldowns.map((item) => ({ ...item })); |
| }, |
| setInlineURLs(urls) { |
| sandbox.inlineURLTableData = Array.isArray(urls) ? urls.slice() : []; |
| }, |
| async runTimers() { |
| const callbacks = Array.from(timers.values()); |
| timers.clear(); |
| await Promise.all(callbacks.map((callback) => callback())); |
| }, |
| async changeChannelType(nextType) { |
| const target = getRadio('channelType', nextType); |
| assert.ok(target, `missing channelType radio: ${nextType}`); |
| setCheckedRadio('channelType', nextType); |
| await elements.channelTypeRadios.dispatchEvent({ type: 'change', target }); |
| }, |
| async submitForm() { |
| await elements.channelForm.dispatchEvent({ type: 'submit' }); |
| } |
| }; |
| } |
|
|
| test('channels 编辑弹窗按两行两列展示协议配置', () => { |
| assert.match(html, /id="protocolTransformsContainer"/); |
| assert.match(html, /id="protocolTransformModeContainer"/); |
| assert.match( |
| html, |
| /class="channel-editor-primary-row"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--name"[\s\S]*?data-i18n="channels\.channelName"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--type"[\s\S]*?data-i18n="channels\.modal\.upstreamProtocol"[\s\S]*?id="channelTypeRadios"/ |
| ); |
| assert.match( |
| html, |
| /class="channel-editor-primary-row"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--transforms"[\s\S]*?data-i18n="channels\.modal\.protocolTransforms"[\s\S]*?id="protocolTransformsContainer"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--mode"[\s\S]*?data-i18n="channels\.modal\.protocolTransformMode"[\s\S]*?id="protocolTransformModeContainer"/ |
| ); |
| }); |
|
|
| test('Gemini 协议转换选项在标签后内联渲染实验性提示', () => { |
| const harness = createHarness(); |
|
|
| harness.api.renderProtocolTransformOptions('anthropic', ['gemini']); |
|
|
| assert.match( |
| harness.elements.protocolTransformsContainer.innerHTML, |
| /value="gemini"[\s\S]*?channel-editor-radio-option-copy--with-hint[\s\S]*?>Gemini<\/span>[\s\S]*?class="channel-editor-radio-hint"[\s\S]*?data-i18n="channels\.modal\.protocolTransformsHint"[\s\S]*?额外暴露协议,不含原生上游协议/ |
| ); |
| }); |
|
|
| test('切换 channel type 后会禁用并剔除原生 protocol transform,仅保留额外协议', async () => { |
| const harness = createHarness(); |
| harness.api.initChannelEditorActions(); |
| harness.api.renderProtocolTransformOptions('anthropic', ['openai', 'codex']); |
|
|
| assert.deepEqual( |
| harness.getProtocolTransformValues().map((item) => item.value), |
| ['anthropic', 'codex', 'openai', 'gemini'] |
| ); |
| assert.equal(harness.getProtocolTransformInput('anthropic').disabled, true); |
| assert.equal(harness.getProtocolTransformInput('anthropic').checked, false); |
| assert.equal(harness.getProtocolTransformInput('openai').checked, true); |
| assert.equal(harness.getProtocolTransformInput('codex').checked, true); |
| assert.equal(harness.getProtocolTransformInput('gemini').checked, false); |
|
|
| await harness.changeChannelType('openai'); |
|
|
| assert.deepEqual( |
| harness.getProtocolTransformValues().map((item) => item.value), |
| ['anthropic', 'codex', 'openai', 'gemini'] |
| ); |
| assert.equal(harness.getProtocolTransformInput('openai').disabled, true); |
| assert.equal(harness.getProtocolTransformInput('openai').checked, false); |
| assert.equal(harness.getProtocolTransformInput('codex').checked, true); |
| assert.equal(harness.getProtocolTransformInput('anthropic').checked, false); |
| assert.equal(harness.getProtocolTransformInput('gemini').checked, false); |
| assert.deepEqual( |
| JSON.parse(JSON.stringify(harness.api.getSelectedProtocolTransforms('openai'))), |
| ['codex'] |
| ); |
| }); |
|
|
| test('所有渠道类型都渲染完整四协议集合,仅禁用原生协议', () => { |
| const harness = createHarness(); |
|
|
| for (const channelType of CHANNEL_TYPES) { |
| harness.api.renderProtocolTransformOptions(channelType, ['anthropic', 'codex', 'openai', 'gemini']); |
| assert.deepEqual( |
| harness.getProtocolTransformValues().map((item) => item.value), |
| ['anthropic', 'codex', 'openai', 'gemini'] |
| ); |
| for (const protocol of CHANNEL_TYPES) { |
| const input = harness.getProtocolTransformInput(protocol); |
| assert.ok(input, `missing protocol transform input: ${protocol}`); |
| assert.equal(input.disabled, protocol === channelType); |
| assert.equal(input.checked, protocol !== channelType); |
| } |
| } |
| }); |
|
|
| test('编辑渠道时会回填 protocol_transforms,并禁用原生协议选项', async () => { |
| const harness = createHarness({ |
| channel: { |
| id: 7, |
| name: 'edited-channel', |
| url: 'https://api.example.com', |
| channel_type: 'gemini', |
| protocol_transform_mode: 'upstream', |
| protocol_transforms: ['openai', 'anthropic'], |
| key_strategy: 'sequential', |
| priority: 9, |
| daily_cost_limit: 0, |
| enabled: true, |
| scheduled_check_enabled: false, |
| scheduled_check_model: '', |
| models: [{ model: 'gpt-5.4', redirect_model: '' }] |
| }, |
| apiKeys: [{ api_key: 'sk-live' }] |
| }); |
|
|
| await harness.api.editChannel(7); |
|
|
| assert.equal(harness.getRadio('channelType', 'gemini').checked, true); |
| assert.equal(harness.getProtocolTransformInput('gemini').disabled, true); |
| assert.equal(harness.getProtocolTransformInput('gemini').checked, false); |
| assert.equal(harness.getProtocolTransformInput('anthropic').checked, true); |
| assert.equal(harness.getProtocolTransformInput('openai').checked, true); |
| assert.equal(harness.getProtocolTransformModeInput('upstream').checked, true); |
| assert.deepEqual( |
| harness.getProtocolTransformValues().filter((item) => item.checked).map((item) => item.value).sort(), |
| ['anthropic', 'openai'] |
| ); |
| }); |
|
|
| test('编辑渠道时会回填 API Key 禁用状态', async () => { |
| const harness = createHarness({ |
| channel: { |
| id: 7, |
| name: 'edited-channel', |
| url: 'https://api.example.com', |
| channel_type: 'anthropic', |
| protocol_transform_mode: 'upstream', |
| protocol_transforms: [], |
| key_strategy: 'sequential', |
| priority: 9, |
| daily_cost_limit: 0, |
| enabled: true, |
| scheduled_check_enabled: false, |
| scheduled_check_model: '', |
| models: [{ model: 'gpt-5.4', redirect_model: '' }] |
| }, |
| apiKeys: [ |
| { key_index: 0, api_key: 'sk-live', cooldown_until: 0, disabled: false }, |
| { key_index: 1, api_key: 'sk-disabled', cooldown_until: 0, disabled: true } |
| ] |
| }); |
|
|
| await harness.api.editChannel(7); |
|
|
| assert.deepEqual(harness.getCurrentKeyCooldowns(), [ |
| { key_index: 0, cooldown_remaining_ms: 0, disabled: false }, |
| { key_index: 1, cooldown_remaining_ms: 0, disabled: true } |
| ]); |
| }); |
|
|
| test('编辑渠道会并行读取定时检测配置和 API Keys,避免串行等待', async () => { |
| const setting = createDeferred(); |
| const keys = createDeferred(); |
| const harness = createHarness({ |
| channel: { |
| id: 7, |
| name: 'edited-channel', |
| url: 'https://api.example.com', |
| channel_type: 'gemini', |
| protocol_transform_mode: 'upstream', |
| protocol_transforms: ['openai'], |
| key_strategy: 'sequential', |
| priority: 9, |
| daily_cost_limit: 0, |
| enabled: true, |
| scheduled_check_enabled: false, |
| scheduled_check_model: '', |
| models: [{ model: 'gpt-5.4', redirect_model: '' }] |
| }, |
| channelCheckIntervalResponse: setting.promise, |
| apiKeysResponse: keys.promise |
| }); |
|
|
| const editPromise = harness.api.editChannel(7); |
| await Promise.resolve(); |
| await Promise.resolve(); |
|
|
| assert.deepEqual(harness.dataFetchCalls, [ |
| '/admin/settings/channel_check_interval_hours', |
| '/admin/channels/7/keys' |
| ]); |
|
|
| setting.resolve({ value: 24 }); |
| keys.resolve([{ api_key: 'sk-live' }]); |
| await editPromise; |
| }); |
|
|
| test('URL 含 # 时转换方式禁用上游并强制选择 ccLoad', () => { |
| const harness = createHarness(); |
| harness.setInlineURLs(['https://api.example.com/v1/chat/completions#']); |
|
|
| harness.api.renderProtocolTransformModeOptions('upstream'); |
|
|
| assert.equal(harness.getProtocolTransformModeInput('upstream').disabled, true); |
| assert.equal(harness.getProtocolTransformModeInput('upstream').checked, false); |
| assert.equal(harness.getProtocolTransformModeInput('local').checked, true); |
| assert.equal(harness.api.getSelectedProtocolTransformMode(), 'local'); |
| }); |
|
|
| test('保存含 # URL 的渠道时 payload 不能提交 upstream 转换方式', async () => { |
| const harness = createHarness(); |
| harness.api.initChannelEditorActions(); |
| harness.setInlineURLs(['https://api.example.com/v1/chat/completions#']); |
| harness.setCheckedRadio('channelType', 'openai'); |
| harness.api.renderProtocolTransformOptions('openai', ['anthropic']); |
| harness.elements.protocolTransformModeContainer.innerHTML = ` |
| <label><input type="radio" name="protocolTransformMode" value="local"></label> |
| <label><input type="radio" name="protocolTransformMode" value="upstream" checked></label> |
| `; |
|
|
| await harness.submitForm(); |
|
|
| const payload = JSON.parse(harness.fetchCalls[1].options.body); |
| assert.equal(payload.protocol_transform_mode, 'local'); |
| }); |
|
|
| test('重复渠道提前提示会忽略调度前未完成的旧检测结果', async () => { |
| const staleResult = createDeferred(); |
| const currentResult = createDeferred(); |
| const harness = createHarness({ |
| duplicateResponses: [staleResult.promise, currentResult.promise] |
| }); |
|
|
| harness.setInlineURLs(['https://stale.example.com']); |
| const staleRefresh = harness.api.refreshChannelDuplicateHint(); |
| assert.equal(harness.fetchCalls.length, 1); |
|
|
| harness.setInlineURLs(['https://current.example.com']); |
| harness.api.scheduleChannelDuplicateHintCheck(); |
| assert.equal(harness.fetchCalls.length, 1); |
|
|
| staleResult.resolve({ |
| success: true, |
| data: { |
| duplicates: [{ name: 'stale-channel', channel_type: 'anthropic', url: 'https://stale.example.com' }] |
| } |
| }); |
| await staleRefresh; |
|
|
| assert.equal(harness.elements.channelDuplicateHint.hidden, true); |
| assert.equal(harness.elements.channelDuplicateHint.textContent, ''); |
|
|
| const currentRefresh = harness.runTimers(); |
| assert.equal(harness.fetchCalls.length, 2); |
|
|
| currentResult.resolve({ |
| success: true, |
| data: { |
| duplicates: [{ name: 'current-channel', channel_type: 'anthropic', url: 'https://current.example.com' }] |
| } |
| }); |
| await currentRefresh; |
|
|
| assert.equal(harness.elements.channelDuplicateHint.hidden, false); |
| assert.match(harness.elements.channelDuplicateHint.textContent, /current-channel/); |
| assert.doesNotMatch(harness.elements.channelDuplicateHint.textContent, /stale-channel/); |
| }); |
|
|
| test('复制渠道会按复制后的 URL 触发重复渠道提前检测', async () => { |
| const harness = createHarness({ |
| channel: { |
| id: 7, |
| name: 'source-channel', |
| url: 'https://api.example.com', |
| channel_type: 'anthropic', |
| protocol_transform_mode: 'upstream', |
| protocol_transforms: [], |
| key_strategy: 'sequential', |
| priority: 9, |
| daily_cost_limit: 0, |
| enabled: true, |
| scheduled_check_enabled: false, |
| scheduled_check_model: '', |
| models: [{ model: 'claude-3-7-sonnet', redirect_model: '' }] |
| }, |
| apiKeys: [{ api_key: 'sk-live' }], |
| duplicateResponses: [{ success: true, data: { duplicates: [] } }] |
| }); |
|
|
| await harness.api.copyChannel(7, 'source-channel'); |
| await harness.runTimers(); |
|
|
| const duplicateCall = harness.fetchCalls.find((call) => call.path === '/admin/channels/check-duplicate'); |
| assert.ok(duplicateCall); |
| assert.deepEqual(JSON.parse(duplicateCall.options.body), { |
| channel_type: 'anthropic', |
| urls: ['https://api.example.com'] |
| }); |
| }); |
|
|
| test('保存渠道时 payload 带上 protocol_transforms', async () => { |
| const harness = createHarness(); |
| harness.api.initChannelEditorActions(); |
| harness.setCheckedRadio('channelType', 'gemini'); |
| harness.api.renderProtocolTransformOptions('gemini', ['anthropic', 'openai']); |
| harness.elements.protocolTransformModeContainer.innerHTML = ` |
| <label><input type="radio" name="protocolTransformMode" value="local"></label> |
| <label><input type="radio" name="protocolTransformMode" value="upstream" checked></label> |
| `; |
|
|
| await harness.submitForm(); |
|
|
| assert.equal(harness.fetchCalls.length, 2); |
| assert.equal(harness.fetchCalls[0].path, '/admin/channels/check-duplicate'); |
| const { path: requestPath, options } = harness.fetchCalls[1]; |
| assert.equal(requestPath, '/admin/channels'); |
| assert.equal(options.method, 'POST'); |
|
|
| const payload = JSON.parse(options.body); |
| assert.deepEqual(payload.protocol_transforms, ['anthropic', 'openai']); |
| assert.equal(payload.protocol_transform_mode, 'upstream'); |
| assert.equal(payload.channel_type, 'gemini'); |
| assert.deepEqual(JSON.parse(JSON.stringify(harness.getAfterSavePayload())), { |
| isNewChannel: true, |
| newChannelType: 'gemini', |
| savedChannelId: null, |
| response: { success: true } |
| }); |
| }); |
|
|
| test('保存渠道提交后立即禁用保存按钮,避免慢请求期间没有反馈', async () => { |
| const save = createDeferred(); |
| const harness = createHarness({ |
| channel: { |
| id: 7, |
| name: 'edited-channel', |
| url: 'https://api.example.com', |
| channel_type: 'anthropic', |
| protocol_transform_mode: 'upstream', |
| protocol_transforms: [], |
| key_strategy: 'sequential', |
| priority: 9, |
| daily_cost_limit: 0, |
| enabled: true, |
| scheduled_check_enabled: false, |
| scheduled_check_model: '', |
| models: [{ model: 'claude-3-7-sonnet', redirect_model: '' }] |
| }, |
| saveResponse: save.promise |
| }); |
| harness.api.initChannelEditorActions(); |
| harness.setEditingChannelId(7); |
| harness.api.renderProtocolTransformOptions('anthropic', []); |
| harness.api.renderProtocolTransformModeOptions('upstream'); |
|
|
| const submitPromise = harness.submitForm(); |
| await Promise.resolve(); |
|
|
| assert.equal(harness.elements.channelSaveBtn.disabled, true); |
|
|
| save.resolve({ success: true }); |
| await submitPromise; |
| assert.equal(harness.elements.channelSaveBtn.disabled, false); |
| }); |
|
|