Files
ai-kefu-admin/admin/src/views/config/center/__tests__/model.test.ts
T

627 lines
24 KiB
TypeScript
Raw Normal View History

/**
* 配置中心提示词/词表/凭据编辑模型单元测试(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('拒绝')
})
})