bf923d9cf7
- 后端 POST /tenants/{slug}/email/test:resolve_email_settings 当前配置
真发测试邮件(主题 [all-kefu] SMTP 测试邮件+租户名+UTC+8 时间);
回退后 host/user 仍空 → 400;成败如实返回 {sent, error},密钥不进响应
- 后端 POST /tenants/{slug}/push/test:连通性探测——与 _do_post 同款
连接形态 POST 空 messages 批量(红线:绝不携带线索数据);不可达
reason 分类 dns/timeout/connect(Windows 裸 ConnectError 文案兜底),
auth_ok 三态(2xx 真 / 401·403 假 / 其余 null),单次无重试
- email_service 抽 _smtp_send 共用连接逻辑(_send 契约不变)+
send_test_email(成败+错误串如实返回,不走吞异常 bool)
- 前端 CredsPane:邮件/推送/AI 三组凭据表单(数值/邮箱前端预检 +
后端 400 双层);密钥哨兵交互(__SET__ 占位符/不动保存保持库值/
清除按钮显式清空可撤销);SMTP 测试与推送探测三态内联展示
- 测试:pytest 新增 18(email/test 7 + push/test 11),全量 595 通过;
vitest 新增 20(model 哨兵往返/预检边界 + API 调用形态),107 通过
Co-Authored-By: Claude Code <noreply@anthropic.com>
627 lines
24 KiB
TypeScript
627 lines
24 KiB
TypeScript
/**
|
||
* 配置中心提示词/词表/凭据编辑模型单元测试(Phase 3 Task 7b + 7c)
|
||
*
|
||
* 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源:
|
||
* app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与
|
||
* app/engine/wall_schema.py 的结构/双形态正则条目约定):
|
||
* - 提示词:UTF-8 字节口径(中文 3 字节/字)、50/100KB 上限判定、
|
||
* 原样快照 → 表单值的防御归一;
|
||
* - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的
|
||
* 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的
|
||
* 保留、buildWallPayload 的整组重组;
|
||
* - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构;
|
||
* - 凭据三组(Task 7c):normalize(掩码哨兵态/数值转串/缺键防御)、
|
||
* build(密钥哨兵往返——不动保存回 '__SET__' 保持库值;空串 = 清空;
|
||
* 新值优先;数值空串 → null)与 validate 前端预检(端口 1-65535 /
|
||
* 邮箱含 @ / push URL 协议与正数·1-10 整数边界,与后端校验同款)。
|
||
*/
|
||
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
CC_TEMPLATE_VARIANTS,
|
||
DEFAULT_TOUCHSTONE_SENTENCES,
|
||
NO_CONTACT_KEYS,
|
||
POOL_FIELDS,
|
||
PROMPTS_CAPS,
|
||
PROMPTS_KEYS,
|
||
PUSH_PROBE_REASON_LABELS,
|
||
REMINDER_VARIANTS,
|
||
TRIGGER_KEYS,
|
||
ZERO_NUMBER_KEYS,
|
||
asSecretMasked,
|
||
buildAiCredsPayload,
|
||
buildEmailCredsPayload,
|
||
buildPushCredsPayload,
|
||
buildWallPayload,
|
||
formatKB,
|
||
initWallEdit,
|
||
normalizeAiCredsGroup,
|
||
normalizeEmailCredsGroup,
|
||
normalizePromptsGroup,
|
||
normalizePushCredsGroup,
|
||
parsePatternEntry,
|
||
promptByteInfo,
|
||
promptsOverCap,
|
||
rowToEntry,
|
||
rowsToEntries,
|
||
rowToSingleEntry,
|
||
touchstoneSentences,
|
||
utf8Bytes,
|
||
validateEmailCredsForm,
|
||
validatePushCredsForm,
|
||
type EmailCredsForm,
|
||
type PromptsDraft,
|
||
type PushCredsForm
|
||
} from '../model'
|
||
|
||
// ---- 提示词四件套 -------------------------------------------------------------------
|
||
|
||
describe('utf8Bytes / formatKB(UTF-8 字节口径)', () => {
|
||
it('ASCII 1 字节/字符、中文 3 字节/字、空串为 0', () => {
|
||
expect(utf8Bytes('')).toBe(0)
|
||
expect(utf8Bytes('abc')).toBe(3)
|
||
expect(utf8Bytes('中文')).toBe(6) // 后端 len('中文'.encode('utf-8')) == 6
|
||
expect(utf8Bytes('价格500')).toBe(3 * 2 + 3)
|
||
})
|
||
|
||
it('formatKB:KB 一位小数', () => {
|
||
expect(formatKB(0)).toBe('0.0KB')
|
||
expect(formatKB(50 * 1024)).toBe('50.0KB')
|
||
expect(formatKB(1536)).toBe('1.5KB')
|
||
})
|
||
})
|
||
|
||
describe('提示词字节上限(config_validation.PROMPTS_SIZE_CAPS 镜像)', () => {
|
||
it('company_facts 50KB、其余 100KB', () => {
|
||
expect(PROMPTS_CAPS.company_facts).toBe(50 * 1024)
|
||
for (const key of PROMPTS_KEYS) {
|
||
if (key !== 'company_facts') expect(PROMPTS_CAPS[key]).toBe(100 * 1024)
|
||
}
|
||
})
|
||
|
||
it('边界:恰好等于上限不算超限,超 1 字节算', () => {
|
||
expect(promptByteInfo('company_facts', '').over).toBe(false)
|
||
expect(promptByteInfo('company_facts', '中'.repeat(50 * 1024 / 3)).over).toBe(false)
|
||
expect(promptByteInfo('company_facts', `a`.repeat(50 * 1024 + 1)).over).toBe(true)
|
||
expect(promptByteInfo('system', 'a'.repeat(100 * 1024)).over).toBe(false)
|
||
expect(promptByteInfo('evaluator', 'a'.repeat(100 * 1024 + 1)).over).toBe(true)
|
||
})
|
||
|
||
it('promptsOverCap:任一件超限即 true(禁存判定)', () => {
|
||
const draft: PromptsDraft = {
|
||
system: '',
|
||
company_facts: 'a'.repeat(50 * 1024 + 1),
|
||
extractor: '',
|
||
evaluator: ''
|
||
}
|
||
expect(promptsOverCap(draft)).toBe(true)
|
||
expect(promptsOverCap(normalizePromptsGroup({}))).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('normalizePromptsGroup(原样快照 → 表单值)', () => {
|
||
it('缺键/非字符串补空串、字符串原样保留', () => {
|
||
expect(
|
||
normalizePromptsGroup({ system: 'S', company_facts: 123, extractor: null })
|
||
).toEqual({ system: 'S', company_facts: '', extractor: '', evaluator: '' })
|
||
expect(normalizePromptsGroup(null)).toEqual({
|
||
system: '',
|
||
company_facts: '',
|
||
extractor: '',
|
||
evaluator: ''
|
||
})
|
||
})
|
||
})
|
||
|
||
// ---- 正则条目双形态 -----------------------------------------------------------------
|
||
|
||
describe('parsePatternEntry(字符串/dict 双形态 → 编辑行)', () => {
|
||
it('纯字符串条目:无 flags 无备注', () => {
|
||
expect(parsePatternEntry('\\$\\s?\\d')).toEqual({
|
||
pattern: '\\$\\s?\\d',
|
||
flags: '',
|
||
comment: '',
|
||
showComment: false
|
||
})
|
||
})
|
||
|
||
it('dict 条目:pattern/flags/comment 各自归位', () => {
|
||
expect(
|
||
parsePatternEntry({ pattern: '\\d+', flags: 'i', comment: '# 时间承诺' })
|
||
).toEqual({
|
||
pattern: '\\d+',
|
||
flags: 'i',
|
||
comment: '# 时间承诺',
|
||
showComment: false
|
||
})
|
||
})
|
||
|
||
it('dict 条目无 comment 键(wall.json 真实形态)与异常值防御', () => {
|
||
expect(parsePatternEntry({ pattern: 'warranty', flags: 'i' }).comment).toBe('')
|
||
expect(parsePatternEntry({ pattern: 'x' }).flags).toBe('')
|
||
// 异常值:数字/null/缺 pattern → 归空行(存量脏数据防御)
|
||
expect(parsePatternEntry(42).pattern).toBe('')
|
||
expect(parsePatternEntry(null).pattern).toBe('')
|
||
expect(parsePatternEntry({ flags: 'i' }).pattern).toBe('')
|
||
expect(parsePatternEntry({ pattern: 1, flags: 'x', comment: 2 })).toEqual({
|
||
pattern: '',
|
||
flags: '',
|
||
comment: '',
|
||
showComment: false
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('rowToEntry / rowsToEntries(编辑行 → 条目序列化)', () => {
|
||
it('无 flags 无备注 → 纯字符串(与 wall.json 极简条目一致)', () => {
|
||
expect(
|
||
rowToEntry({ pattern: '\\$\\s?\\d', flags: '', comment: '', showComment: false })
|
||
).toBe('\\$\\s?\\d')
|
||
})
|
||
|
||
it('有 flags/备注 → dict 且只带非空键(comment-only 条目不带 flags 键)', () => {
|
||
expect(
|
||
rowToEntry({ pattern: '\\d+', flags: 'i', comment: '', showComment: false })
|
||
).toEqual({ pattern: '\\d+', flags: 'i' })
|
||
expect(
|
||
rowToEntry({ pattern: 'x', flags: '', comment: '# 说明', showComment: false })
|
||
).toEqual({ pattern: 'x', comment: '# 说明' })
|
||
expect(
|
||
rowToEntry({ pattern: 'x', flags: 'i', comment: '# 说明', showComment: false })
|
||
).toEqual({ pattern: 'x', flags: 'i', comment: '# 说明' })
|
||
})
|
||
|
||
it('空 pattern 行剔除、纯空白备注视为无备注', () => {
|
||
expect(
|
||
rowToEntry({ pattern: '', flags: 'i', comment: 'x', showComment: false })
|
||
).toBeNull()
|
||
expect(
|
||
rowsToEntries([
|
||
{ pattern: 'a', flags: '', comment: ' ', showComment: false },
|
||
{ pattern: '', flags: '', comment: '', showComment: false },
|
||
{ pattern: 'b', flags: 'i', comment: '', showComment: false }
|
||
])
|
||
).toEqual(['a', { pattern: 'b', flags: 'i' }])
|
||
})
|
||
|
||
it('rowToSingleEntry(triggers/zero_number):无标志非空 → 纯字符串;空 pattern → dict 显性失败', () => {
|
||
expect(
|
||
rowToSingleEntry({ pattern: 'price', flags: 'i', comment: '', showComment: false })
|
||
).toEqual({ pattern: 'price', flags: 'i' })
|
||
// wall.json zero_number 出厂是纯字符串——无标志无备注保持字符串(往返无损)
|
||
expect(
|
||
rowToSingleEntry({ pattern: 'budget|预算', flags: '', comment: '', showComment: false })
|
||
).toBe('budget|预算')
|
||
// 空 pattern 绝不能出字符串形态('' 会编成匹配一切的正则)——dict 让后端报路径
|
||
expect(
|
||
rowToSingleEntry({ pattern: '', flags: '', comment: '', showComment: false })
|
||
).toEqual({ pattern: '' })
|
||
})
|
||
})
|
||
|
||
// ---- 词表整体模型 -------------------------------------------------------------------
|
||
|
||
/** 最小但结构完整的 wall 快照(对照 seed_profiles/jiancai/wall.json 形态) */
|
||
function miniWall(): Record<string, unknown> {
|
||
return {
|
||
price_patterns: [
|
||
'\\$\\s?\\d',
|
||
{ pattern: '\\d+\\s*天', comment: '# 中文时限' },
|
||
{ pattern: 'warranty', flags: 'i' }
|
||
],
|
||
deflections: { en: ['A?'], zh: ['B?'], es: ['C?'] },
|
||
deflections_contact: { en: ['A.'], zh: ['B.'] },
|
||
pivots: { en: ['P'], zh: ['p'] },
|
||
pivots_contact: { en: ['Q'], zh: ['q'] },
|
||
no_contact: { web: 'W', app: 'A', already: 'D' },
|
||
zero_number: {
|
||
long_number: '\\+?\\d[\\d\\s\\-().]{8,17}\\d',
|
||
any_email: '[a-z]+@[a-z]+\\.[a-z]{2,}'
|
||
},
|
||
reask_patterns: [{ pattern: 'your\\s+whatsapp', flags: 'i' }],
|
||
triggers: {
|
||
boundary: { pattern: 'price', flags: 'i' },
|
||
budget: { pattern: 'budget|预算', flags: 'i' },
|
||
contact_ask: { pattern: 'your\\s+number', flags: 'i' }
|
||
},
|
||
reminders: {
|
||
boundary: { budget: 'r-b', normal: 'r-n' },
|
||
contact_ask: { app_already: 'ca-aa', app: 'ca-a', web: 'ca-w' },
|
||
language_follow: { zh: 'lf-zh', es: 'lf-es' },
|
||
lead_given: {
|
||
forbidden: 'lg-f',
|
||
respond_script: 'lg-r',
|
||
boundary_tail: 'lg-b',
|
||
suspect: 'lg-s'
|
||
},
|
||
cc_guide: { echo: 'cg-e', ask: 'cg-a' },
|
||
email_light: { en: 'el-en', zh: 'el-zh' }
|
||
},
|
||
cc_templates: {
|
||
ask: { en: 't-a-en', zh: 't-a-zh', es: 't-a-es' },
|
||
wall: { en: 't-w-en', zh: 't-w-zh', es: 't-w-es' },
|
||
note: { en: 't-n-en', zh: 't-n-zh' },
|
||
echo: { en: 't-e-en', zh: 't-e-zh', es: 't-e-es' }
|
||
},
|
||
_meta: { source: 'extract_config.py' }
|
||
}
|
||
}
|
||
|
||
describe('initWallEdit(快照 → 编辑模型)', () => {
|
||
it('缺失/空对象/非对象 → null(面板空态)', () => {
|
||
expect(initWallEdit(null)).toBeNull()
|
||
expect(initWallEdit(undefined)).toBeNull()
|
||
expect(initWallEdit({})).toBeNull()
|
||
expect(initWallEdit('x' as unknown as Record<string, unknown>)).toBeNull()
|
||
})
|
||
|
||
it('双形态正则解析 + 透传键保留 + 六类/四组变体补齐', () => {
|
||
const model = initWallEdit(miniWall())!
|
||
expect(model.priceRows).toHaveLength(3)
|
||
expect(model.priceRows[0]).toMatchObject({ pattern: '\\$\\s?\\d', flags: '' })
|
||
expect(model.priceRows[1]).toMatchObject({
|
||
pattern: '\\d+\\s*天',
|
||
flags: '',
|
||
comment: '# 中文时限'
|
||
})
|
||
expect(model.priceRows[2]).toMatchObject({ pattern: 'warranty', flags: 'i' })
|
||
expect(model.reaskRows).toHaveLength(1)
|
||
expect(model.singles.boundary).toMatchObject({ pattern: 'price', flags: 'i' })
|
||
expect(model.singles.long_number.pattern).toBe('\\+?\\d[\\d\\s\\-().]{8,17}\\d')
|
||
expect(model.pools.deflections).toEqual({ en: ['A?'], zh: ['B?'], es: ['C?'] })
|
||
expect(model.noContact).toEqual({ web: 'W', app: 'A', already: 'D' })
|
||
expect(model.reminders.boundary.budget).toBe('r-b')
|
||
expect(model.ccTemplates.note).toEqual({ en: 't-n-en', zh: 't-n-zh' })
|
||
// 未接管键(含 "_" 前缀元信息)原样透传
|
||
expect(model.passthrough).toEqual({ _meta: { source: 'extract_config.py' } })
|
||
})
|
||
|
||
it('脏数据防御:非字符串池项剔除、非对象区补空、非列表正则区为空', () => {
|
||
const model = initWallEdit({
|
||
price_patterns: 'oops',
|
||
deflections: { en: ['ok', 42, null], zh: 'oops', es: [] },
|
||
triggers: 'oops',
|
||
no_contact: 'oops',
|
||
reminders: 'oops',
|
||
cc_templates: 'oops'
|
||
})!
|
||
expect(model.priceRows).toEqual([])
|
||
expect(model.pools.deflections).toEqual({ en: ['ok'] })
|
||
expect(model.singles.boundary.pattern).toBe('')
|
||
expect(model.noContact.web).toBe('')
|
||
expect(model.reminders.boundary.budget).toBe('')
|
||
expect(model.ccTemplates.ask.en).toBe('')
|
||
})
|
||
})
|
||
|
||
describe('buildWallPayload(编辑模型 → PUT config/wall 整组载荷)', () => {
|
||
it('结构化区重组 + 透传键合入 + 序列化规则生效', () => {
|
||
const model = initWallEdit(miniWall())!
|
||
// 编辑:价格墙加一条(rich 形态)+ 留一条空行(应被剔除);
|
||
// deflections.en 加一条空句(应被剔除);en 池清空句子(整键应被丢弃)
|
||
model.priceRows.push({ pattern: '\\d+k\\b', flags: 'i', comment: '', showComment: false })
|
||
model.priceRows.push({ pattern: '', flags: '', comment: '', showComment: false })
|
||
model.pools.deflections.en.push('')
|
||
model.pools.deflections_contact.en = ['']
|
||
|
||
const payload = buildWallPayload(model)
|
||
|
||
expect(payload.price_patterns).toEqual([
|
||
'\\$\\s?\\d',
|
||
{ pattern: '\\d+\\s*天', comment: '# 中文时限' },
|
||
{ pattern: 'warranty', flags: 'i' },
|
||
{ pattern: '\\d+k\\b', flags: 'i' }
|
||
])
|
||
expect(payload.reask_patterns).toEqual([{ pattern: 'your\\s+whatsapp', flags: 'i' }])
|
||
expect(payload.triggers).toEqual({
|
||
boundary: { pattern: 'price', flags: 'i' },
|
||
budget: { pattern: 'budget|预算', flags: 'i' },
|
||
contact_ask: { pattern: 'your\\s+number', flags: 'i' }
|
||
})
|
||
expect(payload.zero_number).toEqual({
|
||
long_number: '\\+?\\d[\\d\\s\\-().]{8,17}\\d',
|
||
any_email: '[a-z]+@[a-z]+\\.[a-z]{2,}'
|
||
})
|
||
expect(payload.deflections).toEqual({ en: ['A?'], zh: ['B?'], es: ['C?'] })
|
||
// deflections_contact.en 清空后整键丢弃(zh 保留)
|
||
expect(payload.deflections_contact).toEqual({ zh: ['B.'] })
|
||
expect(payload.no_contact).toEqual({ web: 'W', app: 'A', already: 'D' })
|
||
expect(payload.reminders).toEqual(miniWall().reminders)
|
||
expect(payload.cc_templates).toEqual(miniWall().cc_templates)
|
||
expect(payload._meta).toEqual({ source: 'extract_config.py' })
|
||
})
|
||
|
||
it('initWallEdit → buildWallPayload 无编辑时结构等价往返(序列化形态按约定归一)', () => {
|
||
const source = miniWall()
|
||
const payload = buildWallPayload(initWallEdit(source)!)
|
||
expect(payload.price_patterns).toEqual(source.price_patterns)
|
||
expect(payload.triggers).toEqual(source.triggers)
|
||
expect(payload.pivots).toEqual(source.pivots)
|
||
expect(payload.reminders).toEqual(source.reminders)
|
||
expect(payload.cc_templates).toEqual(source.cc_templates)
|
||
expect(payload._meta).toEqual(source._meta)
|
||
})
|
||
})
|
||
|
||
// ---- 变体键镜像与试金石 -------------------------------------------------------------
|
||
|
||
describe('变体键镜像(wall_schema 常量同构)', () => {
|
||
it('reminders 六类 / cc_templates 四组 / 池与单正则键清单', () => {
|
||
expect(Object.keys(REMINDER_VARIANTS).sort()).toEqual(
|
||
['boundary', 'cc_guide', 'contact_ask', 'email_light', 'language_follow', 'lead_given']
|
||
)
|
||
expect(Object.keys(CC_TEMPLATE_VARIANTS).sort()).toEqual(['ask', 'echo', 'note', 'wall'])
|
||
expect(POOL_FIELDS).toEqual([
|
||
'deflections',
|
||
'deflections_contact',
|
||
'pivots',
|
||
'pivots_contact'
|
||
])
|
||
expect([...TRIGGER_KEYS]).toEqual(['boundary', 'budget', 'contact_ask'])
|
||
expect([...ZERO_NUMBER_KEYS]).toEqual(['long_number', 'any_email'])
|
||
expect([...NO_CONTACT_KEYS]).toEqual(['web', 'app', 'already'])
|
||
})
|
||
})
|
||
|
||
describe('试金石句集', () => {
|
||
it('默认 3 示例句(覆盖 blocked/replaced/pass 三态)且非空', () => {
|
||
expect(DEFAULT_TOUCHSTONE_SENTENCES).toHaveLength(3)
|
||
for (const s of DEFAULT_TOUCHSTONE_SENTENCES) expect(s.trim()).not.toBe('')
|
||
})
|
||
|
||
it('touchstoneSentences:trim + 空行过滤', () => {
|
||
expect(touchstoneSentences([' a ', '', ' ', 'b'])).toEqual(['a', 'b'])
|
||
expect(touchstoneSentences([])).toEqual([])
|
||
})
|
||
})
|
||
|
||
// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) -----------------------------------
|
||
|
||
describe('asSecretMasked(GET 密钥值 → 掩码哨兵态)', () => {
|
||
it('哨兵原样 / 空串与 null·undefined → 空串(区分「配了」与「没配」)', () => {
|
||
expect(asSecretMasked('__SET__')).toBe('__SET__')
|
||
expect(asSecretMasked('')).toBe('')
|
||
expect(asSecretMasked(null)).toBe('')
|
||
expect(asSecretMasked(undefined)).toBe('')
|
||
})
|
||
|
||
it('掩码失效漏出的任意非空值也归哨兵(防原始密钥回传库外)', () => {
|
||
expect(asSecretMasked('sk-real-secret')).toBe('__SET__')
|
||
expect(asSecretMasked(123)).toBe('')
|
||
})
|
||
})
|
||
|
||
describe('normalize 三组(GET 原样组 → 表单 + 密钥态)', () => {
|
||
it('email:smtp 完整组 → 表单值 + 密码哨兵;密钥输入恒空', () => {
|
||
const state = normalizeEmailCredsGroup({
|
||
smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '__SET__' },
|
||
sender_name: 'HESUPIPE',
|
||
notify_emails: ['a@x.com', 'b@x.com']
|
||
})
|
||
expect(state.form).toEqual({
|
||
smtpHost: 'smtp.x.com',
|
||
smtpPort: '465',
|
||
smtpUser: 'bot@x.com',
|
||
smtpPassword: '',
|
||
senderName: 'HESUPIPE',
|
||
notifyEmails: ['a@x.com', 'b@x.com']
|
||
})
|
||
expect(state.passwordMasked).toBe('__SET__')
|
||
})
|
||
|
||
it('email:jiancai 档案形态(无 smtp 键)→ 全空槽 + 未设置密钥态', () => {
|
||
const state = normalizeEmailCredsGroup({
|
||
sender_name: 'HESUPIPE',
|
||
notify_emails: ['hesu@hesuu.com']
|
||
})
|
||
expect(state.form.smtpHost).toBe('')
|
||
expect(state.form.smtpPort).toBe('')
|
||
expect(state.form.smtpPassword).toBe('')
|
||
expect(state.passwordMasked).toBe('')
|
||
})
|
||
|
||
it('email:smtp null / 类型漂移防御', () => {
|
||
const state = normalizeEmailCredsGroup({ smtp: null, notify_emails: [1, 'a@x.com'] })
|
||
expect(state.form.smtpHost).toBe('')
|
||
expect(state.form.notifyEmails).toEqual(['a@x.com'])
|
||
expect(normalizeEmailCredsGroup(undefined).form.notifyEmails).toEqual([])
|
||
})
|
||
|
||
it('push:完整组 → 表单(数值转串)+ api_key 哨兵', () => {
|
||
const state = normalizePushCredsGroup({
|
||
enabled: true,
|
||
url: 'https://push.example/v1',
|
||
api_key: '__SET__',
|
||
prefix: 'ai-kefu-jc',
|
||
timeout: 5.5,
|
||
max_attempts: 3,
|
||
retry_backoff_base: 1
|
||
})
|
||
expect(state.form).toEqual({
|
||
enabled: true,
|
||
url: 'https://push.example/v1',
|
||
apiKey: '',
|
||
prefix: 'ai-kefu-jc',
|
||
timeout: '5.5',
|
||
maxAttempts: '3',
|
||
backoffBase: '1'
|
||
})
|
||
expect(state.apiKeyMasked).toBe('__SET__')
|
||
})
|
||
|
||
it('push:缺省/未启用 → enabled false + 数值空串(null → 空槽)', () => {
|
||
const state = normalizePushCredsGroup({ prefix: 'ai-kefu-jc' })
|
||
expect(state.form.enabled).toBe(false)
|
||
expect(state.form.timeout).toBe('')
|
||
expect(state.form.maxAttempts).toBe('')
|
||
expect(state.form.backoffBase).toBe('')
|
||
expect(state.form.apiKey).toBe('')
|
||
expect(state.apiKeyMasked).toBe('')
|
||
})
|
||
|
||
it('ai:缺省组 → 空表单(出厂默认由后端深合并兜底)+ 未设置密钥态', () => {
|
||
const state = normalizeAiCredsGroup(null)
|
||
expect(state.form).toEqual({ provider: '', apiKey: '', baseUrl: '', model: '' })
|
||
expect(state.apiKeyMasked).toBe('')
|
||
const set = normalizeAiCredsGroup({
|
||
provider: 'glm',
|
||
api_key: '__SET__',
|
||
model: 'glm-4-flash'
|
||
})
|
||
expect(set.form.provider).toBe('glm')
|
||
expect(set.form.apiKey).toBe('')
|
||
expect(set.apiKeyMasked).toBe('__SET__')
|
||
})
|
||
})
|
||
|
||
describe('build 三组载荷(表单 → PUT 整组)+ 密钥哨兵往返', () => {
|
||
it('email:普通字段 trim、空行剔除、端口转整数', () => {
|
||
const payload = buildEmailCredsPayload(
|
||
{
|
||
smtpHost: ' smtp.x.com ',
|
||
smtpPort: ' 465 ',
|
||
smtpUser: ' bot@x.com ',
|
||
smtpPassword: '',
|
||
senderName: ' HESUPIPE ',
|
||
notifyEmails: [' a@x.com ', '', ' ']
|
||
},
|
||
''
|
||
)
|
||
expect(payload).toEqual({
|
||
smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '' },
|
||
sender_name: 'HESUPIPE',
|
||
notify_emails: ['a@x.com']
|
||
})
|
||
})
|
||
|
||
it('email 密钥三态:未设置不动 → 空串;已设置不动 → 哨兵;输入新值 → 新值', () => {
|
||
const form = (): EmailCredsForm => ({
|
||
smtpHost: 'smtp.x.com',
|
||
smtpPort: '465',
|
||
smtpUser: 'bot@x.com',
|
||
smtpPassword: '',
|
||
senderName: '',
|
||
notifyEmails: []
|
||
})
|
||
// 已设置 + 未动 → 哨兵(后端还原库值,库值不变)
|
||
expect(buildEmailCredsPayload(form(), '__SET__').smtp).toMatchObject({
|
||
password: '__SET__'
|
||
})
|
||
// 未设置 + 未动 → 空串(「清除」意图同为空串 = 显式清空)
|
||
expect(buildEmailCredsPayload(form(), '').smtp).toMatchObject({ password: '' })
|
||
// 输入新值 → 新值优先(无论原态)
|
||
const typed = { ...form(), smtpPassword: 'new-pw' }
|
||
expect(buildEmailCredsPayload(typed, '__SET__').smtp).toMatchObject({
|
||
password: 'new-pw'
|
||
})
|
||
})
|
||
|
||
it('push:数值空串 → null(未设置哨兵)、enabled 布尔原样、密钥哨兵往返', () => {
|
||
const payload = buildPushCredsPayload(
|
||
{
|
||
enabled: true,
|
||
url: ' https://push.example/v1 ',
|
||
apiKey: '',
|
||
prefix: 'demo-p',
|
||
timeout: '5.5',
|
||
maxAttempts: '3',
|
||
backoffBase: ''
|
||
},
|
||
'__SET__'
|
||
)
|
||
expect(payload).toEqual({
|
||
enabled: true,
|
||
url: 'https://push.example/v1',
|
||
api_key: '__SET__',
|
||
prefix: 'demo-p',
|
||
timeout: 5.5,
|
||
max_attempts: 3,
|
||
retry_backoff_base: null
|
||
})
|
||
})
|
||
|
||
it('ai:api_key 哨兵/新值/清空三态 + 字段 trim', () => {
|
||
const base = { provider: ' glm ', apiKey: '', baseUrl: ' https://x.com ', model: ' glm-4 ' }
|
||
expect(buildAiCredsPayload({ ...base }, '__SET__')).toEqual({
|
||
provider: 'glm',
|
||
api_key: '__SET__',
|
||
base_url: 'https://x.com',
|
||
model: 'glm-4'
|
||
})
|
||
expect(buildAiCredsPayload({ ...base, apiKey: 'sk-new' }, '').api_key).toBe('sk-new')
|
||
expect(buildAiCredsPayload({ ...base }, '').api_key).toBe('')
|
||
})
|
||
})
|
||
|
||
describe('validate 前端预检(后端 400 的前置层)', () => {
|
||
it('email:端口非整数/越界报错;空串合法(= 平台默认)', () => {
|
||
const ok: EmailCredsForm = {
|
||
smtpHost: '',
|
||
smtpPort: '',
|
||
smtpUser: '',
|
||
smtpPassword: '',
|
||
senderName: '',
|
||
notifyEmails: []
|
||
}
|
||
expect(validateEmailCredsForm(ok)).toEqual([])
|
||
for (const bad of ['0', '65536', 'abc', '1.5', '-1']) {
|
||
expect(validateEmailCredsForm({ ...ok, smtpPort: bad })).toHaveLength(1)
|
||
}
|
||
expect(validateEmailCredsForm({ ...ok, smtpPort: '465' })).toEqual([])
|
||
expect(validateEmailCredsForm({ ...ok, smtpPort: '65535' })).toEqual([])
|
||
})
|
||
|
||
it('email:通知邮箱非空行须含 @(空行提交时剔除,不报错)', () => {
|
||
const base: EmailCredsForm = {
|
||
smtpHost: '',
|
||
smtpPort: '',
|
||
smtpUser: '',
|
||
smtpPassword: '',
|
||
senderName: '',
|
||
notifyEmails: []
|
||
}
|
||
expect(validateEmailCredsForm({ ...base, notifyEmails: ['', ' '] })).toEqual([])
|
||
expect(
|
||
validateEmailCredsForm({ ...base, notifyEmails: ['a@x.com', 'not-an-email'] })
|
||
).toHaveLength(1)
|
||
})
|
||
|
||
it('push:URL 协议 / 正数 / 1-10 整数边界(与后端 _check_push 同款)', () => {
|
||
const ok: PushCredsForm = {
|
||
enabled: false,
|
||
url: 'https://push.example/v1',
|
||
apiKey: '',
|
||
prefix: '',
|
||
timeout: '10',
|
||
maxAttempts: '3',
|
||
backoffBase: '1'
|
||
}
|
||
expect(validatePushCredsForm(ok)).toEqual([])
|
||
// URL 空串合法(= 平台默认);非 http(s) 协议报错
|
||
expect(validatePushCredsForm({ ...ok, url: '' })).toEqual([])
|
||
expect(validatePushCredsForm({ ...ok, url: 'ftp://x' })).toHaveLength(1)
|
||
// timeout 正数
|
||
expect(validatePushCredsForm({ ...ok, timeout: '0' })).toHaveLength(1)
|
||
expect(validatePushCredsForm({ ...ok, timeout: '-1' })).toHaveLength(1)
|
||
expect(validatePushCredsForm({ ...ok, timeout: 'abc' })).toHaveLength(1)
|
||
expect(validatePushCredsForm({ ...ok, timeout: '0.5' })).toEqual([])
|
||
// max_attempts 1-10 整数
|
||
expect(validatePushCredsForm({ ...ok, maxAttempts: '0' })).toHaveLength(1)
|
||
expect(validatePushCredsForm({ ...ok, maxAttempts: '11' })).toHaveLength(1)
|
||
expect(validatePushCredsForm({ ...ok, maxAttempts: '2.5' })).toHaveLength(1)
|
||
expect(validatePushCredsForm({ ...ok, maxAttempts: '10' })).toEqual([])
|
||
// backoff 正数;全部空串 = 平台默认,合法
|
||
expect(validatePushCredsForm({ ...ok, backoffBase: '0' })).toHaveLength(1)
|
||
expect(
|
||
validatePushCredsForm({ ...ok, url: '', timeout: '', maxAttempts: '', backoffBase: '' })
|
||
).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () => {
|
||
it('覆盖后端 reason 全部分类(dns/timeout/connect)', () => {
|
||
expect(PUSH_PROBE_REASON_LABELS.dns).toContain('域名解析')
|
||
expect(PUSH_PROBE_REASON_LABELS.timeout).toContain('超时')
|
||
expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝')
|
||
})
|
||
})
|