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:
@@ -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
@@ -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 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
|
||||
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂
|
||||
|
||||
Reference in New Issue
Block a user