import { describe, expect, it } from 'vitest'; import { isBound, pickSystemUse, toPublicView, validateBinding } from '../internal/domain/model-binding.ts'; import type { ModelBinding } from '../internal/domain/model-binding.ts'; import { callFingerprint, replayKey, stableStringify } from '../internal/domain/replay-key.ts'; import type { TaskId } from '@contracts'; const binding = (over: Partial = {}): ModelBinding => ({ id: 'i1', name: 'quote-extractor', provider: 'openai-compatible', baseUrl: 'https://api.example.com/v1', model: 'gpt-4o-mini', apiKeyCipher: 'v1.aa.bb.cc', systemUse: false, enabled: true, ...over, }); describe('validateBinding —— 四元齐备才算绑定', () => { it('四元齐备通过', () => { expect(validateBinding(binding())).toEqual([]); expect(isBound(binding())).toBe(true); }); it('缺 API-Key 即未绑定', () => { const v = validateBinding(binding({ apiKeyCipher: undefined })); expect(v.map((x) => x.field)).toEqual(['apiKey']); expect(isBound(binding({ apiKeyCipher: undefined }))).toBe(false); }); it('URL 必须是 http(s)', () => { expect(validateBinding(binding({ baseUrl: 'ftp://x' })).map((x) => x.field)).toEqual(['baseUrl']); expect(validateBinding(binding({ baseUrl: '' })).map((x) => x.field)).toEqual(['baseUrl']); }); it('模型名不得为空', () => { expect(validateBinding(binding({ model: ' ' })).map((x) => x.field)).toEqual(['model']); }); it('自定义模型名有字符集约束', () => { expect(validateBinding(binding({ name: '模型 一' })).map((x) => x.field)).toEqual(['name']); }); }); describe('toPublicView —— 结构上不可能泄漏密钥', () => { it('只回是否已绑定', () => { const view = toPublicView(binding()); expect(view).toEqual({ id: 'i1', name: 'quote-extractor', provider: 'openai-compatible', baseUrl: 'https://api.example.com/v1', model: 'gpt-4o-mini', apiKeyBound: true, systemUse: false, enabled: true, }); expect(JSON.stringify(view)).not.toContain('v1.aa.bb.cc'); }); it('未绑定时 apiKeyBound = false', () => { expect(toPublicView(binding({ apiKeyCipher: '' })).apiKeyBound).toBe(false); }); }); describe('pickSystemUse', () => { it('取启用且标记系统用途的实例', () => { const picked = pickSystemUse([ binding({ id: 'a', name: 'a', systemUse: false }), binding({ id: 'b', name: 'b', systemUse: true, enabled: false }), binding({ id: 'c', name: 'c', systemUse: true }), ]); expect(picked?.id).toBe('c'); }); it('没有即 undefined', () => { expect(pickSystemUse([])).toBeUndefined(); }); }); describe('replayKey —— 按任务 × 步序号', () => { it('有任务与步序号时用定位键', () => { expect(replayKey({ model: 'm', prompt: 'p', taskId: 't1' as TaskId, stepNo: 3 })).toBe('task:t1#3'); }); it('缺定位时退到内容指纹,且同输入同键', () => { const a = replayKey({ model: 'm', prompt: 'p', temperature: 0 }); const b = replayKey({ model: 'm', prompt: 'p', temperature: 0 }); expect(a).toBe(b); expect(a.startsWith('fp:')).toBe(true); }); it('不同 prompt 得不同键', () => { expect(replayKey({ model: 'm', prompt: 'p1' })).not.toBe(replayKey({ model: 'm', prompt: 'p2' })); }); it('指纹不含凭证字段', () => { expect(callFingerprint({ model: 'm', prompt: 'p' })).not.toMatch(/key|token|authorization/i); }); }); describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () => { it('对象键排序', () => { expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 })); }); it('嵌套与数组', () => { expect(stableStringify({ a: [1, { d: 4, c: 3 }] })).toBe('{"a":[1,{"c":3,"d":4}]}'); }); it('null 与标量', () => { expect(stableStringify(null)).toBe('null'); expect(stableStringify(3)).toBe('3'); }); }); describe('系统用途:没人勾也要有着落(2026-07-30)', () => { const mk = (over: Partial[0][number]>) => ({ id: 'x', name: 'n', provider: 'deepseek' as const, baseUrl: 'https://api.deepseek.com', model: 'm', apiKeyCipher: 'cipher', systemUse: false, enabled: true, ...over, }); it('勾了的优先', () => { const flagged = mk({ id: 'flagged', systemUse: true }); expect(pickSystemUse([mk({ id: 'first' }), flagged])?.id).toBe('flagged'); }); it('一个没勾 → 取第一条能用的(启用且已绑密钥)', () => { const picked = pickSystemUse([mk({ id: 'nokey', apiKeyCipher: undefined }), mk({ id: 'off', enabled: false }), mk({ id: 'ok' })]); expect(picked?.id, '未绑密钥与已停用的都不算数').toBe('ok'); }); it('一条能用的都没有 → 如实返回 undefined,不硬凑一条', () => { expect(pickSystemUse([mk({ apiKeyCipher: undefined })])).toBeUndefined(); }); });