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>
This commit is contained in:
浅念
2026-10-13 20:55:28 +08:00
parent 39d72c53aa
commit bf923d9cf7
11 changed files with 2024 additions and 19 deletions
+70 -1
View File
@@ -1,5 +1,6 @@
/**
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石)
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石;
* Task 7c——凭据测试端点)
*
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
@@ -9,6 +10,9 @@
* 包装),备注走 comment 查询参数,无备注时不带 params
* - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演,
* 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
* 数据)——两者 showErrorMessage: false 内联展示
* - 头像上传:multipart file 字段(FormData)
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
*/
@@ -24,6 +28,8 @@ import {
fetchTenantConfig,
normalizeBrandGroup,
putConfigGroup,
testEmail,
testPush,
testWall,
uploadBrandAvatar
} from '@/api/config'
@@ -194,6 +200,69 @@ describe('试金石 API(testWall · Phase 3 Task 7b)', () => {
})
})
describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('testEmail:POST email/test,body 只带 {to}(收件人)', async () => {
const response = { slug: 'jiancai', sent: true, error: null }
vi.mocked(http.post).mockResolvedValue(response)
const res = await testEmail('jiancai', { to: 'you@example.com' }, {
showErrorMessage: false
})
expect(res).toEqual(response)
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/email/test',
{ to: 'you@example.com' },
{ showErrorMessage: false } // 400「SMTP 未配置」等内联展示,不走全局提示
)
})
it('testEmail:发送失败也走成功响应通道(sent=false + error 串,非异常)', async () => {
const response = { slug: 'jiancai', sent: false, error: 'SMTPRecipientsRefused(...)' }
vi.mocked(http.post).mockResolvedValue(response)
const res = await testEmail('jiancai', { to: 'you@example.com' })
expect(res.sent).toBe(false)
expect(res.error).toContain('SMTP')
})
it('testPush:POST push/test,无请求体(探测读已保存配置)', async () => {
const response = {
slug: 'jiancai',
reachable: false,
auth_ok: null,
status: null,
reason: 'dns',
error: 'Failed to resolve.'
}
vi.mocked(http.post).mockResolvedValue(response)
const res = await testPush('jiancai', { showErrorMessage: false })
expect(res).toEqual(response)
expect(http.post).toHaveBeenCalledTimes(1)
const [url, body, config] = vi.mocked(http.post).mock.calls[0]
expect(url).toBe('/api/platform/tenants/jiancai/push/test')
expect(body).toBeUndefined() // 端点不读 body——探测参数全来自已保存配置
expect(config).toEqual({ showErrorMessage: false })
})
it('CONFIG_ENDPOINTS.emailTest / pushTest:端点路径与后端 config_routes 一致', () => {
expect(CONFIG_ENDPOINTS.emailTest('jiancai')).toBe(
'/api/platform/tenants/jiancai/email/test'
)
expect(CONFIG_ENDPOINTS.pushTest('jiancai')).toBe(
'/api/platform/tenants/jiancai/push/test'
)
})
})
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
+72 -1
View File
@@ -25,6 +25,14 @@
* 400 带字段路径;has_contact / source 可选(默认 false / web);
* - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→
* 零号码墙(replaced)→ 再索要墙(仅 has_contact,blocked 无替换)。
*
* ## Task 7c 凭据测试契约(config_routes.test_email / test_push)
*
* - POST email/test:body {to}——后端用**已保存**的当前邮件配置真发一封
* 测试邮件;SMTP 未配置 → 400;成败如实返回(sent=false 时带 error);
* - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索
* 数据**(后端发空 messages 批量);不可达时 reason 分类
* dns/timeout/connect,auth_ok 三态(true/false/null=不定)。
*/
import { http, type RequestOptions } from './client'
@@ -132,6 +140,32 @@ export interface AvatarUploadResponse {
version: number
}
/** SMTP 测试邮件请求(POST .../email/test;用**已保存**配置真发一封到 to) */
export interface EmailTestRequest {
to: string
}
/** SMTP 测试邮件响应(发送失败不 5xx——sent=false 时 error 带原因) */
export interface EmailTestResponse {
slug: string
sent: boolean
error: string | null
}
/** 推送探测不可达原因分类(前端据此给修复提示文案) */
export type PushProbeReason = 'dns' | 'timeout' | 'connect'
/** 推送连通性探测响应(POST .../push/test;空批量预检,零线索数据) */
export interface PushTestResponse {
slug: string
reachable: boolean
/** true=2xx / false=401·403 / null=其他状态(到达但鉴权不定)或不可达 */
auth_ok: boolean | null
status: number | null
reason: PushProbeReason | null
error: string | null
}
/** 配置中心端点 */
export const CONFIG_ENDPOINTS = {
current: (slug: string) => `/api/platform/tenants/${slug}/config`,
@@ -144,7 +178,9 @@ export const CONFIG_ENDPOINTS = {
`/api/platform/tenants/${slug}/config/versions/${version}`,
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`,
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`,
emailTest: (slug: string) => `/api/platform/tenants/${slug}/email/test`,
pushTest: (slug: string) => `/api/platform/tenants/${slug}/push/test`
} as const
/**
@@ -214,6 +250,41 @@ export function uploadBrandAvatar(
)
}
/**
* SMTP 测试邮件(POST .../email/test):用**已保存**的当前邮件配置真发一封
* 到 to——表单里未保存的编辑不生效(界面需提示"先保存再测试")。SMTP 未
* 配置 → 400(调用方 showErrorMessage: false 自行内联展示);发送失败
* 不抛错——sent=false 时 error 带原因串。
*/
export function testEmail(
slug: string,
body: EmailTestRequest,
config?: RequestOptions
): Promise<EmailTestResponse> {
return http.post<EmailTestResponse>(
CONFIG_ENDPOINTS.emailTest(slug),
body,
config
)
}
/**
* 推送连通性探测(POST .../push/test):只验 URL 可达 + 鉴权,后端发空
* messages 批量(零线索数据)。URL 未配置 → 400;结果三态由调用方按
* reachable/auth_ok 展示(不可达 reason 分类 dns/timeout/connect)。
* 同样用**已保存**配置——改完 URL 先保存再探测。
*/
export function testPush(
slug: string,
config?: RequestOptions
): Promise<PushTestResponse> {
return http.post<PushTestResponse>(
CONFIG_ENDPOINTS.pushTest(slug),
undefined,
config
)
}
/**
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂