import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { assertValidOneBotConfig, loadOneBotConfig, makeDefaultOneBotConfig, prepareOneBotConfigForRestore, saveOneBotConfig, STATUS_COMMAND_TRIGGER_MAX_LENGTH, } from '../src/config'; const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; describe('makeDefaultOneBotConfig', () => { it('returns default unified networks structure', () => { const config = makeDefaultOneBotConfig(); expect(config.networks.httpServers).toHaveLength(1); expect(config.networks.httpServers[0].name).toBe('http-default'); expect(config.networks.httpServers[0].host).toBe('127.0.0.1'); expect(config.networks.httpServers[0].port).toBe(3000); expect(config.networks.httpServers[0].accessToken).toMatch(TOKEN_PATTERN); expect(config.networks.httpServers[0].messageFormat).toBe('array'); expect(config.networks.httpServers[0].reportSelfMessage).toBe(false); expect(config.networks.httpClients).toEqual([]); expect(config.networks.wsServers).toHaveLength(1); expect(config.networks.wsServers[0].host).toBe('127.0.0.1'); expect(config.networks.wsServers[0].port).toBe(3001); expect(config.networks.wsServers[0].role).toBe('Universal'); expect(config.networks.wsServers[0].accessToken).toMatch(TOKEN_PATTERN); expect(config.networks.wsServers[0].messageFormat).toBe('array'); expect(config.networks.wsServers[0].reportSelfMessage).toBe(false); expect(config.networks.wsClients).toEqual([]); expect(config.statusCommand).toEqual({ enabled: true, swallow: false, cooldownSeconds: 5, trigger: '#sl', }); expect(config.historySync).toEqual({ enabled: false }); expect(config.notifications).toEqual({ channelIds: [] }); }); }); describe('assertValidOneBotConfig', () => { it('rejects duplicate adapter names across different kinds', () => { const config = makeDefaultOneBotConfig(); config.networks.httpClients.push({ name: 'http-default', url: 'http://127.0.0.1:5700', messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(config)).toThrow( /duplicated in httpServers and httpClients/, ); }); it('allows a disabled client draft with an empty URL', () => { const config = makeDefaultOneBotConfig(); config.networks.httpClients.push({ name: 'disabled-draft', enabled: false, url: '', messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(config)).not.toThrow(); }); it('rejects enabled clients with malformed or wrong-protocol URLs', () => { const http = makeDefaultOneBotConfig(); http.networks.httpClients.push({ name: 'bad-http', url: 'not a url', messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(http)).toThrow(/valid absolute URL/); const ws = makeDefaultOneBotConfig(); ws.networks.wsClients.push({ name: 'bad-ws', url: 'https://example.com/socket', messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(ws)).toThrow(/protocol must be one of ws:, wss:/); }); it('rejects enabled servers with the same normalized host and port', () => { const config = makeDefaultOneBotConfig(); config.networks.httpServers[0].host = '0.0.0.0'; config.networks.wsServers.push({ name: 'conflicting-ws', host: '0.0.0.0', port: 3000, path: '/different-path', messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(config)).toThrow( /conflicts with networks\.httpServers\[0\] on server binding 0\.0\.0\.0:3000/, ); }); it('rejects blank hosts and wildcard/specific listeners on the same port', () => { const blank = makeDefaultOneBotConfig(); blank.networks.httpServers[0].host = ' '; expect(() => assertValidOneBotConfig(blank)).toThrow(/\.host/); const wildcard = makeDefaultOneBotConfig(); wildcard.networks.httpServers[0].host = '0.0.0.0'; wildcard.networks.wsServers.push({ name: 'specific-ws', host: '127.0.0.1', port: 3000, path: '/ws', messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(wildcard)).toThrow(/wildcard server port 3000/); }); it('rejects timer values that Node would clamp to a 1ms loop', () => { const http = makeDefaultOneBotConfig(); http.networks.httpClients.push({ name: 'overflow-http', url: 'http://127.0.0.1:5700', timeoutMs: 1e100, messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(http)).toThrow(/timeoutMs/); const ws = makeDefaultOneBotConfig(); ws.networks.wsClients.push({ name: 'overflow-ws', url: 'ws://127.0.0.1:5700', reconnectIntervalMs: 2_147_483_648, messageFormat: 'array', reportSelfMessage: false, }); expect(() => assertValidOneBotConfig(ws)).toThrow(/reconnectIntervalMs/); }); it('bounds maxPayloadBytes when one is configured', () => { for (const invalid of [0, 1023, 2 * 1024 * 1024 * 1024 + 1, 1024.5, '104857600']) { const config = makeDefaultOneBotConfig(); config.networks.wsServers[0].maxPayloadBytes = invalid as unknown as number; expect(() => assertValidOneBotConfig(config), String(invalid)).toThrow( /maxPayloadBytes/, ); } const accepted = makeDefaultOneBotConfig(); accepted.networks.wsServers[0].maxPayloadBytes = 1024; expect(() => assertValidOneBotConfig(accepted)).not.toThrow(); // The third WebSocket-capable adapter: httpServers with the embedded WS. const httpWs = makeDefaultOneBotConfig(); httpWs.networks.httpServers[0].enableWebSocket = true; httpWs.networks.httpServers[0].maxPayloadBytes = 2048; expect(() => assertValidOneBotConfig(httpWs)).not.toThrow(); }); it('rejects maxPayloadBytes on the pure HTTP client adapter', () => { const config = makeDefaultOneBotConfig(); config.networks.httpClients.push({ name: 'mirror', url: 'http://127.0.0.1:9000', messageFormat: 'array', reportSelfMessage: false, }); // HttpPostAdapter has no WebSocket transport, so the value could only be // saved and silently ignored. (config.networks.httpClients[0] as unknown as Record).maxPayloadBytes = 1024; expect(() => assertValidOneBotConfig(config)).toThrow( /maxPayloadBytes is only supported by WebSocket adapters/, ); }); it('rejects server paths that cannot match a URL pathname', () => { for (const invalidPath of ['api', '/api?token=x', '/api#fragment', ' /api']) { const config = makeDefaultOneBotConfig(); config.networks.httpServers[0].path = invalidPath; expect(() => assertValidOneBotConfig(config), invalidPath).toThrow(/\.path/); } const valid = makeDefaultOneBotConfig(); valid.networks.httpServers[0].path = ''; expect(() => assertValidOneBotConfig(valid)).not.toThrow(); }); }); describe('saveOneBotConfig validation boundary', () => { it('does not write an invalid enabled-client URL to disk', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-invalid-save-')); const previous = process.cwd(); process.chdir(tempDir); try { const config = makeDefaultOneBotConfig(); config.networks.wsClients.push({ name: 'bad-client', url: 'javascript:alert(1)', messageFormat: 'array', reportSelfMessage: false, }); expect(() => saveOneBotConfig('10001', config)).toThrow( /protocol must be one of ws:, wss:/, ); expect(fs.existsSync(path.join(tempDir, 'config', 'onebot_10001.json'))).toBe(false); } finally { process.chdir(previous); fs.rmSync(tempDir, { recursive: true, force: true }); } }); it('does not write a deterministic server bind conflict to disk', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-bind-conflict-')); const previous = process.cwd(); process.chdir(tempDir); try { const config = makeDefaultOneBotConfig(); config.networks.wsServers.push({ name: 'same-bind', host: '127.0.0.1', port: 3000, path: '/ws', messageFormat: 'array', reportSelfMessage: false, }); expect(() => saveOneBotConfig('10001', config)).toThrow( /server binding 127\.0\.0\.1:3000/, ); expect(fs.existsSync(path.join(tempDir, 'config', 'onebot_10001.json'))).toBe(false); } finally { process.chdir(previous); fs.rmSync(tempDir, { recursive: true, force: true }); } }); it('preserves the previous desired config when the atomic rename fails', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-atomic-save-')); const previous = process.cwd(); process.chdir(tempDir); try { const original = makeDefaultOneBotConfig(); saveOneBotConfig('10001', original); const changed = structuredClone(original); changed.networks.httpServers[0].port = 3999; const rename = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw new Error('injected rename failure'); }); expect(() => saveOneBotConfig('10001', changed)).toThrow(/injected rename failure/); rename.mockRestore(); expect(loadOneBotConfig('10001').networks.httpServers[0].port).toBe(3000); expect( fs.readdirSync(path.join(tempDir, 'config')).some((name) => name.endsWith('.tmp')), ).toBe(false); } finally { vi.restoreAllMocks(); process.chdir(previous); fs.rmSync(tempDir, { recursive: true, force: true }); } }); }); describe('loadOneBotConfig', () => { let tempDir: string; let prevCwd: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-config-')); prevCwd = process.cwd(); process.chdir(tempDir); }); afterEach(() => { process.chdir(prevCwd); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('creates and persists default config when none exists', () => { const uin = '10001'; const config = loadOneBotConfig(uin, { persistDefaults: true }); expect(config.networks.httpServers).toHaveLength(1); const onDisk = JSON.parse( fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'), ); expect(onDisk.networks).toBeDefined(); expect(Array.isArray(onDisk.networks.httpServers)).toBe(true); expect(onDisk.networks.httpServers[0].name).toBe('http-default'); expect(onDisk.networks.httpServers[0].accessToken).toMatch(TOKEN_PATTERN); expect(onDisk.networks.wsServers[0].accessToken).toMatch(TOKEN_PATTERN); // Legacy keys must not appear in the persisted file. expect(onDisk.httpServers).toBeUndefined(); expect(onDisk.wsServers).toBeUndefined(); // statusCommand is materialised with defaults on a fresh install. expect(onDisk.statusCommand).toEqual({ enabled: true, swallow: false, cooldownSeconds: 5, trigger: '#sl', }); }); it('fills statusCommand defaults and clamps a negative cooldown', () => { const uin = '10042'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, statusCommand: { swallow: true, cooldownSeconds: -3 }, }), ); const config = loadOneBotConfig(uin); expect(config.statusCommand.enabled).toBe(true); // default filled (absent in file) expect(config.statusCommand.swallow).toBe(true); // taken from file expect(config.statusCommand.cooldownSeconds).toBe(0); // negative clamped to 0 }); it('migrates legacy per-type arrays into networks groups', () => { const uin = '10002'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); const legacy = { httpServers: [{ host: '0.0.0.0', port: 3100, path: '/', accessToken: 'tok' }], httpPostEndpoints: [{ name: 'main-bot', url: 'http://127.0.0.1:5700' }], wsServers: [{ host: '0.0.0.0', port: 3101 }], wsClients: [{ url: 'ws://127.0.0.1:8080' }], musicSignUrl: 'https://example.com/sign', messageFormat: 'string', reportSelfMessage: true, }; fs.writeFileSync(path.join(dir, `onebot_${uin}.json`), JSON.stringify(legacy), 'utf8'); const config = loadOneBotConfig(uin, { persistDefaults: true }); expect(config.networks.httpServers).toHaveLength(1); expect(config.networks.httpServers[0].port).toBe(3100); expect(config.networks.httpServers[0].accessToken).toBe('tok'); expect(config.networks.httpServers[0].messageFormat).toBe('string'); expect(config.networks.httpServers[0].reportSelfMessage).toBe(true); expect(config.networks.httpClients).toHaveLength(1); expect(config.networks.httpClients[0].name).toBe('main-bot'); expect(config.networks.httpClients[0].url).toBe('http://127.0.0.1:5700'); expect(config.networks.httpClients[0].messageFormat).toBe('string'); expect(config.networks.httpClients[0].reportSelfMessage).toBe(true); expect(config.networks.wsServers).toHaveLength(1); expect(config.networks.wsServers[0].port).toBe(3101); expect(config.networks.wsServers[0].messageFormat).toBe('string'); expect(config.networks.wsServers[0].reportSelfMessage).toBe(true); expect(config.networks.wsClients).toHaveLength(1); expect(config.networks.wsClients[0].url).toBe('ws://127.0.0.1:8080'); expect(config.networks.wsClients[0].messageFormat).toBe('string'); expect(config.networks.wsClients[0].reportSelfMessage).toBe(true); // File should now be in unified format on disk. const onDisk = JSON.parse(fs.readFileSync(path.join(dir, `onebot_${uin}.json`), 'utf8')); expect(onDisk.networks).toBeDefined(); expect(onDisk.httpServers).toBeUndefined(); expect(onDisk.httpPostEndpoints).toBeUndefined(); expect(onDisk.messageFormat).toBeUndefined(); expect(onDisk.reportSelfMessage).toBeUndefined(); expect(onDisk.networks.httpServers[0].messageFormat).toBe('string'); expect(onDisk.networks.httpServers[0].reportSelfMessage).toBe(true); }); it('round-trips per-adapter overrides through save/load', () => { const uin = '10003'; const config = makeDefaultOneBotConfig(); config.networks.httpClients.push({ name: 'self-mirror', url: 'http://127.0.0.1:9000', messageFormat: 'string', reportSelfMessage: true, }); saveOneBotConfig(uin, config); const reloaded = loadOneBotConfig(uin); expect(reloaded.networks.httpClients).toHaveLength(1); expect(reloaded.networks.httpClients[0].name).toBe('self-mirror'); expect(reloaded.networks.httpClients[0].messageFormat).toBe('string'); expect(reloaded.networks.httpClients[0].reportSelfMessage).toBe(true); }); it('round-trips maxPayloadBytes (both directions) and omits it when unset', () => { const uin = '10020'; const config = makeDefaultOneBotConfig(); config.networks.wsServers[0].maxPayloadBytes = 512 * 1024 * 1024; config.networks.wsClients.push({ name: 'astrbot', url: 'ws://127.0.0.1:6199/ws', messageFormat: 'array', reportSelfMessage: false, maxPayloadBytes: 256 * 1024 * 1024, }); saveOneBotConfig(uin, config); const onDisk = JSON.parse( fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'), ); expect(onDisk.networks.wsServers[0].maxPayloadBytes).toBe(512 * 1024 * 1024); expect(onDisk.networks.wsClients[0].maxPayloadBytes).toBe(256 * 1024 * 1024); // Unset adapters must not gain the key, so existing configs stay byte-stable. expect(onDisk.networks.httpServers[0].maxPayloadBytes).toBeUndefined(); const reloaded = loadOneBotConfig(uin); expect(reloaded.networks.wsServers[0].maxPayloadBytes).toBe(512 * 1024 * 1024); expect(reloaded.networks.wsClients[0]!.maxPayloadBytes).toBe(256 * 1024 * 1024); const prepared = prepareOneBotConfigForRestore(onDisk, 'per-uin'); const preparedNetworks = prepared.value.networks as { wsClients: Array>; }; expect(preparedNetworks.wsClients[0]!.maxPayloadBytes).toBe(256 * 1024 * 1024); const junk = structuredClone(onDisk) as { networks: { wsClients: Array> }; }; for (const value of [0, '268435456', 1024.5, null]) { junk.networks.wsClients[0]!.maxPayloadBytes = value; expect(() => prepareOneBotConfigForRestore(junk, 'per-uin')).toThrow(/maxPayloadBytes/); } }); it.each(['httpServers', 'wsServers', 'wsClients'] as const)( 'rejects invalid on-disk maxPayloadBytes for %s without rewriting the file', (kind) => { const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, 'onebot_10023.json'); for (const value of [-1, 0, 1023, 1024.5, 2 * 1024 ** 3 + 1, '2048', null, true]) { const raw = JSON.stringify({ mode: 'snapshot', networks: { [kind]: [ { name: 'invalid-limit', port: 3001, url: 'ws://127.0.0.1:3001', enableWebSocket: true, maxPayloadBytes: value, }, ], }, }); fs.writeFileSync(file, raw, 'utf8'); expect(() => loadOneBotConfig('10023'), String(value)).toThrow(/maxPayloadBytes/); expect(fs.readFileSync(file, 'utf8')).toBe(raw); } }, ); it('persists maxPayloadBytes for httpServers and rejects it for httpClients', () => { const uin = '10021'; const config = makeDefaultOneBotConfig(); config.networks.httpServers[0].enableWebSocket = true; config.networks.httpServers[0].maxPayloadBytes = 4096; saveOneBotConfig(uin, config); const onDisk = JSON.parse( fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'), ); expect(onDisk.networks.httpServers[0].maxPayloadBytes).toBe(4096); expect(loadOneBotConfig(uin).networks.httpServers[0].maxPayloadBytes).toBe(4096); expect(() => prepareOneBotConfigForRestore(onDisk, 'per-uin')).not.toThrow(); // A restore source that carries the key on a non-WS adapter is rejected // rather than accepted into a config where it can never apply. const withHttpClient = structuredClone(onDisk) as { networks: { httpClients: Array> }; }; withHttpClient.networks.httpClients.push({ name: 'mirror', url: 'http://127.0.0.1:9000', maxPayloadBytes: 4096, }); expect(() => prepareOneBotConfigForRestore(withHttpClient, 'per-uin')).toThrow( /maxPayloadBytes is not supported/, ); // A hand-written file must reject the same unsupported field instead // of discarding it before the canonical validation runs. const dir = path.join(tempDir, 'config'); fs.writeFileSync( path.join(dir, 'onebot_10022.json'), JSON.stringify({ mode: 'snapshot', networks: { httpServers: [], httpClients: [ { name: 'mirror', url: 'http://127.0.0.1:9000', messageFormat: 'array', reportSelfMessage: false, maxPayloadBytes: 4096, }, ], wsServers: [], wsClients: [], }, }), 'utf8', ); expect(() => loadOneBotConfig('10022')).toThrow( /maxPayloadBytes is only supported by WebSocket adapters/, ); }); it('replays a saved per-UIN snapshot without reviving deleted global adapters', () => { const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); const global = makeDefaultOneBotConfig(); global.networks.httpServers[0].name = 'inherited'; fs.writeFileSync(path.join(dir, 'onebot.json'), JSON.stringify(global), 'utf8'); const config = loadOneBotConfig('10004'); expect(config.networks.httpServers.map((item) => item.name)).toContain('inherited'); config.networks.httpServers = []; config.networks.wsServers.push({ name: 'inherited', host: '127.0.0.1', port: 3998, path: '/', role: 'Universal', messageFormat: 'array', reportSelfMessage: false, }); saveOneBotConfig('10004', config); const reloaded = loadOneBotConfig('10004'); expect(reloaded.networks.httpServers).toEqual([]); expect(reloaded.networks.wsServers.map((item) => item.name)).toContain('inherited'); const disk = JSON.parse(fs.readFileSync(path.join(dir, 'onebot_10004.json'), 'utf8')); expect(disk.mode).toBe('snapshot'); }); it('fails fast instead of replacing a corrupt per-UIN desired config with defaults', () => { const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'onebot_10005.json'), '{broken', 'utf8'); expect(() => loadOneBotConfig('10005', { persistDefaults: true })).toThrow(/is corrupt/); expect(fs.readFileSync(path.join(dir, 'onebot_10005.json'), 'utf8')).toBe('{broken'); }); it('treats a valid JSON non-object root as corrupt desired state', () => { const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'onebot_10006.json'), '[]', 'utf8'); expect(() => loadOneBotConfig('10006', { persistDefaults: true })).toThrow( /root must be an object/, ); expect(fs.readFileSync(path.join(dir, 'onebot_10006.json'), 'utf8')).toBe('[]'); }); it('fills new statusCommand fields with defaults when absent', () => { const uin = '10042'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, statusCommand: { enabled: false }, }), ); const config = loadOneBotConfig(uin); expect(config.statusCommand.enabled).toBe(false); // from file expect(config.statusCommand.trigger).toBe('#sl'); // default }); it('clamps trigger length and rejects empty trigger', () => { const uin = '10043'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, statusCommand: { trigger: '' }, }), ); const config = loadOneBotConfig(uin); expect(config.statusCommand.trigger).toBe('#sl'); // empty → default }); it('truncates trigger to max 32 chars', () => { const uin = '10049'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, statusCommand: { trigger: 'a'.repeat(100) }, }), ); const config = loadOneBotConfig(uin); expect(config.statusCommand.trigger.length).toBe(32); }); it('rejects trigger containing newline, falls back to default', () => { const uin = '10050'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, statusCommand: { trigger: '#sl\nbot' }, }), ); const config = loadOneBotConfig(uin); expect(config.statusCommand.trigger).toBe('#sl'); }); it('aligns MAX_LENGTH constant with expected value', () => { expect(STATUS_COMMAND_TRIGGER_MAX_LENGTH).toBe(32); }); it('does not write to disk by default (read-only contract)', () => { const uin = '10006'; const cfgPath = path.join(tempDir, 'config', `onebot_${uin}.json`); // Default call must not materialize the file or mint a fresh access token. const config = loadOneBotConfig(uin); expect(config.networks.httpServers).toHaveLength(1); expect(fs.existsSync(cfgPath)).toBe(false); // Explicit opt-in still writes. loadOneBotConfig(uin, { persistDefaults: true }); expect(fs.existsSync(cfgPath)).toBe(true); }); it('respects an operator-emptied adapter list instead of seeding defaults back', () => { const uin = '10007'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, musicSignUrl: '', }), 'utf8', ); const config = loadOneBotConfig(uin, { persistDefaults: true }); expect(config.networks.httpServers).toEqual([]); expect(config.networks.wsServers).toEqual([]); }); it('migrates legacy lowercase WsRole values into canonical uppercase form', () => { const uin = '10005'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [ { name: 'ws-legacy', host: '0.0.0.0', port: 3201, path: '/', role: 'universal', messageFormat: 'array', reportSelfMessage: false, }, ], wsClients: [ { name: 'wsc-legacy', url: 'ws://127.0.0.1:8080', role: 'event', messageFormat: 'array', reportSelfMessage: false, }, ], }, }), 'utf8', ); const config = loadOneBotConfig(uin); expect(config.networks.wsServers).toHaveLength(1); expect(config.networks.wsServers[0].role).toBe('Universal'); expect(config.networks.wsClients).toHaveLength(1); expect(config.networks.wsClients[0].role).toBe('Event'); }); it('auto-generates names for legacy entries that lack one', () => { const uin = '10004'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ httpServers: [{ port: 3000 }, { port: 3001 }], }), 'utf8', ); const config = loadOneBotConfig(uin); const names = config.networks.httpServers.map((n) => n.name); expect(names).toHaveLength(2); expect(new Set(names).size).toBe(2); // unique expect(names.every((n) => n.length > 0)).toBe(true); expect(config.networks.httpServers.every((n) => n.messageFormat === 'array')).toBe(true); expect(config.networks.httpServers.every((n) => n.reportSelfMessage === false)).toBe(true); }); }); describe('OneBotConfig.notifications (per-UIN channel opt-in)', () => { let tempDir: string; let prevCwd: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-notif-')); prevCwd = process.cwd(); process.chdir(tempDir); }); afterEach(() => { process.chdir(prevCwd); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('defaults to an empty channelIds list when absent', () => { const config = loadOneBotConfig('20001'); expect(config.notifications).toEqual({ channelIds: [] }); }); it('validates, dedupes and drops bad channel ids', () => { const uin = '20002'; const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `onebot_${uin}.json`), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, notifications: { channelIds: ['dingtalk', 'dingtalk', 'bad id!', '', 42, 'feishu'], }, }), ); const config = loadOneBotConfig(uin); expect(config.notifications?.channelIds).toEqual(['dingtalk', 'feishu']); }); it('round-trips channelIds through save/load and persists them', () => { const uin = '20003'; const config = makeDefaultOneBotConfig(); config.notifications = { channelIds: ['discord', 'serverchan'] }; saveOneBotConfig(uin, config); const reloaded = loadOneBotConfig(uin); expect(reloaded.notifications?.channelIds).toEqual(['discord', 'serverchan']); const onDisk = JSON.parse( fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'), ); expect(onDisk.notifications).toEqual({ channelIds: ['discord', 'serverchan'] }); }); }); describe('OneBotConfig login history sync', () => { let tempDir: string; let prevCwd: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-history-sync-')); prevCwd = process.cwd(); process.chdir(tempDir); }); afterEach(() => { process.chdir(prevCwd); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('defaults old configurations to disabled', () => { const config = loadOneBotConfig('30001'); expect(config.historySync).toEqual({ enabled: false }); }); it('round-trips the per-account opt-in', () => { const config = makeDefaultOneBotConfig(); config.historySync.enabled = true; saveOneBotConfig('30002', config); expect(loadOneBotConfig('30002').historySync).toEqual({ enabled: true }); const onDisk = JSON.parse( fs.readFileSync(path.join(tempDir, 'config', 'onebot_30002.json'), 'utf8'), ); expect(onDisk.historySync).toEqual({ enabled: true }); }); it('rejects malformed history sync settings during save and restore', () => { const malformed = makeDefaultOneBotConfig() as unknown as Record; malformed.historySync = { enabled: 'yes' }; expect(() => assertValidOneBotConfig(malformed)).toThrow(/historySync\.enabled/); const source = { networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, historySync: { enabled: true, interval: 1 }, }; expect(() => prepareOneBotConfigForRestore(source, 'per-uin')).toThrow( /\$\.historySync\.interval is not supported/, ); }); it('rejects malformed on-disk settings instead of inheriting an enabled value', () => { const dir = path.join(tempDir, 'config'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, 'onebot.json'), JSON.stringify({ historySync: { enabled: true }, }), 'utf8', ); fs.writeFileSync( path.join(dir, 'onebot_30003.json'), JSON.stringify({ networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] }, historySync: { enabled: 'yes' }, }), 'utf8', ); expect(() => loadOneBotConfig('30003')).toThrow(/historySync\.enabled must be a boolean/); }); }); describe('OneBot listen port environment overrides', () => { let tempDir = ''; let previous = ''; const saved: Record = {}; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-port-env-')); previous = process.cwd(); process.chdir(tempDir); for (const name of ['SNOWLUMA_ONEBOT_HTTP_PORT', 'SNOWLUMA_ONEBOT_WS_PORT']) { saved[name] = process.env[name]; delete process.env[name]; } }); afterEach(() => { for (const name of ['SNOWLUMA_ONEBOT_HTTP_PORT', 'SNOWLUMA_ONEBOT_WS_PORT']) { if (saved[name] === undefined) { delete process.env[name]; } else { process.env[name] = saved[name]; } } process.chdir(previous); fs.rmSync(tempDir, { recursive: true, force: true }); }); it('overrides only the factory listeners and does not write the port back', () => { process.env.SNOWLUMA_ONEBOT_HTTP_PORT = '3800'; process.env.SNOWLUMA_ONEBOT_WS_PORT = '3801'; const loaded = loadOneBotConfig('10001', { persistDefaults: true }); expect(loaded.networks.httpServers[0]?.port).toBe(3800); expect(loaded.networks.wsServers[0]?.port).toBe(3801); const onDisk = JSON.parse( fs.readFileSync(path.join(tempDir, 'config', 'onebot_10001.json'), 'utf8'), ) as { networks: { httpServers: Array<{ port: number }>; wsServers: Array<{ port: number }> }; }; expect(onDisk.networks.httpServers[0]?.port).toBe(3000); expect(onDisk.networks.wsServers[0]?.port).toBe(3001); }); it('leaves a custom listener alone', () => { const config = makeDefaultOneBotConfig(); config.networks.httpServers = [ { ...config.networks.httpServers[0]!, name: 'public-http', port: 4000, }, ]; saveOneBotConfig('10001', config); process.env.SNOWLUMA_ONEBOT_HTTP_PORT = '3800'; const loaded = loadOneBotConfig('10001'); expect(loaded.networks.httpServers.map((server) => server.port)).toEqual([4000]); }); it('keeps the saved port when the override is not a port', () => { process.env.SNOWLUMA_ONEBOT_HTTP_PORT = 'nope'; const loaded = loadOneBotConfig('10001', { persistDefaults: true }); expect(loaded.networks.httpServers[0]?.port).toBe(3000); }); });