Files
ai-kefu-admin/admin/src/views/config/center/__tests__/model.test.ts
T
浅念 bf923d9cf7 feat: 配置中心-凭据(邮件推送AI表单/掩码交互/SMTP测试/推送探测端点)
- 后端 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>
2026-10-13 20:55:40 +08:00

627 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配置中心提示词/词表/凭据编辑模型单元测试(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('拒绝')
})
})