Files
shanxia2.0/web-admin/scripts/mobile-customer-validation.test.ts
2026-10-22 16:24:46 +08:00

634 lines
21 KiB
TypeScript

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import test from 'node:test'
import { computed, ref } from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as model from '../src/views/m/crm/customer/model'
import * as validation from '../src/views/m/crm/customer/validation'
const base = new URL('../src/views/m/crm/customer/', import.meta.url)
test('customer and contact native submissions reach the same explicit validation as save buttons', () => {
for (const file of ['form.vue', 'ContactPanel.vue']) {
const { descriptor } = parse(readFileSync(new URL(file, base), 'utf8'))
assert.match(
descriptor.template!.content,
/<form\b[^>]*\bnovalidate\b[^>]*@submit\.prevent="save"/
)
}
})
// Execute the current production declarations; stub only transport, navigation and UI feedback.
function execute(file: string, names: string[], bindings: Record<string, any>) {
// Validation fixtures keep one record/session; identity transitions have renderer coverage separately.
bindings = {
identity: {
begin: () => ({ id: String(bindings.props?.customerId || bindings.props?.opportunity?.id || ''), valid: () => true })
},
...validation,
...bindings
}
const { descriptor } = parse(readFileSync(new URL(file, base), 'utf8'))
const ast = ts.createSourceFile(
file,
descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const selected = [...names, 'showSaveError']
const source = ast.statements
.filter((node) =>
ts.isFunctionDeclaration(node)
? selected.includes(node.name?.text || '')
: ts.isVariableStatement(node) &&
selected.includes(node.declarationList.declarations[0].name.getText(ast))
)
.map((node) => node.getText(ast))
.join('\n')
return new Function(
...Object.keys(bindings),
`${ts.transpile(source, { target: ts.ScriptTarget.ESNext })}\nreturn { ${names.join(',')} }`
)(...Object.values(bindings))
}
function feedback() {
const notices: string[] = []
const events: string[] = []
return {
notices,
events,
showFailToast: (message: string) => notices.push(message),
emit: (event: string) => events.push(event)
}
}
function contactHarness(edit: boolean, patch: model.Row = {}, write = async () => {}) {
const payloads: model.Row[] = []
const routes: string[] = []
let reloads = 0
const bindings = {
...model,
...feedback(),
acting: ref(false),
saveError: ref(''),
editing: ref(true),
form: ref({
contactName: '测试联系人',
mobile: '13800138000',
telephone: '前台转 123 / 工作日',
email: '',
isKey: 1,
isPrimary: 0,
remark: '保留草稿',
...(edit ? { id: 'contact-1' } : {}),
...patch
}),
props: { customerId: 'customer-1' },
fetchCreateContact: async (data: model.Row) => {
payloads.push(data)
routes.push('create')
await write()
},
fetchUpdateContact: async (data: model.Row) => {
payloads.push(data)
routes.push('update')
await write()
},
load: async () => {
reloads++
}
}
return {
...bindings,
...execute('ContactPanel.vue', ['save'], bindings),
payloads,
routes,
reloads: () => reloads
}
}
for (const edit of [false, true]) {
for (const [key, value, message] of [
['contactName', ' ', '联系人姓名'],
['mobile', '', '手机号'],
['mobile', '123', '手机号'],
['mobile', '12800138000', '手机号'],
['mobile', '138001380000', '手机号'],
['email', 'bad', '邮箱'],
['email', 'a@b', '邮箱']
]) {
test(`contact ${edit ? 'edit' : 'create'} rejects ${key}=${value} before transport`, async () => {
const h = contactHarness(edit, { [key]: value })
const before = { ...h.form.value }
await h.save()
assert.equal(h.payloads.length, 0)
assert.match(h.saveError.value, new RegExp(message))
assert.equal(h.notices.at(-1), h.saveError.value)
assert.deepEqual(h.form.value, before)
assert.equal(h.editing.value, true)
assert.equal(h.acting.value, false)
assert.equal(h.reloads(), 0)
assert.deepEqual(h.events, [])
})
}
for (const email of ['', null, undefined, 'a@example.com', 'first.last+tag@sub.example.co.uk']) {
test(`contact ${edit ? 'edit' : 'create'} accepts optional email ${email} and free-form telephone`, async () => {
const h = contactHarness(edit, { email })
await h.save()
assert.deepEqual(h.routes, [edit ? 'update' : 'create'])
assert.equal(h.payloads[0].telephone, '前台转 123 / 工作日')
assert.equal(h.payloads[0].email, email ?? '')
assert.equal(h.payloads[0].customerId, 'customer-1')
assert.equal(h.payloads[0].isKey, 1)
assert.equal(h.payloads[0].id, edit ? 'contact-1' : undefined)
assert.equal(h.editing.value, false)
assert.equal(h.reloads(), 1)
assert.deepEqual(h.events, ['changed'])
})
}
}
function customerHarness(edit = false, patch: model.Row = {}, actions: model.Row = {}) {
const calls: string[] = []
const payloads: model.Row[] = []
const navigations: string[] = []
const bindings = {
...model,
...feedback(),
saving: ref(false),
error: ref(''),
uploads: new Map(),
id: ref(edit ? 'customer-1' : ''),
state: { customerId: '' },
invoice: ref<model.Row>({}),
form: ref(
model.customerForm({
...(edit ? { id: 'customer-1' } : {}),
customerName: '测试客户',
shortName: '测试',
customerType: 'type',
source: 'source',
parkArea: '新园区',
province: '省',
city: '市',
district: '区',
mobile: '',
email: '',
telephone: '办公总机 / 分机 123',
remark: '保留草稿',
...patch
})
),
dict: {
getOptions: () => [],
ensure: async () => {
calls.push('ensure')
return { value: '新园区' }
}
},
showCrmConfirmDialog: async () => {
calls.push('confirm')
},
fetchCheckDuplicate: async (data: model.Row) => {
calls.push('duplicate')
return actions.duplicate ? actions.duplicate(data) : { isDuplicate: false }
},
fetchCreateCustomer: async (data: model.Row) => {
calls.push('create')
payloads.push(data)
if (actions.write) await actions.write()
return { id: 'created-customer' }
},
fetchUpdateCustomer: async (data: model.Row) => {
calls.push('update')
payloads.push(data)
if (actions.write) await actions.write()
},
addInvoiceInfo: async (data: model.Row) => {
calls.push('invoice')
payloads.push(data)
if (actions.invoice) await actions.invoice()
},
showSuccessToast: () => {},
router: {
replace: async (path: string) => {
navigations.push(path)
}
}
}
return { ...bindings, ...execute('form.vue', ['save'], bindings), calls, payloads, navigations }
}
test('production customer and contact email validation agrees with the Web async-validator public API', async () => {
const require = createRequire(import.meta.url)
const requireFromElement = createRequire(require.resolve('element-plus/package.json'))
const Schema = requireFromElement('async-validator').default
const schema = new Schema({ email: { type: 'email' } })
for (const email of [
'',
null,
undefined,
'bad',
'a@b',
'a@example.com',
'first.last+tag@sub.example.co.uk',
'a..b@example.com',
'.a@example.com',
'a@example.c',
' a@example.com ',
' ',
'"quoted name"@example.com',
'user@[192.168.1.1]',
'名字@例子.中国',
'a@ex-ample.com',
'a@foo_bar.com',
'a@domain.123',
123,
'a'.repeat(307) + '@example.com',
'a'.repeat(309) + '@example.com'
]) {
const valid = await schema.validate({ email }, { suppressWarning: true }).then(
() => true,
() => false
)
for (const h of [contactHarness(false, { email }), customerHarness(false, { email })]) {
await h.save()
assert.equal(h.payloads.length, valid ? 1 : 0, `email=${String(email)}`)
if (!valid) assert.match(h.notices.at(-1) || '', /邮箱/)
}
}
})
for (const edit of [false, true]) {
const invalid = [
...model.customerGroups
.flatMap((g) => g.fields)
.filter((f) => f.required)
.map((f) => [f.key, ' ', f.label]),
['mobile', '123', '手机号'],
['email', 'bad', '邮箱'],
['email', 'a@b', '邮箱']
]
for (const [key, value, label] of invalid) {
test(`customer ${edit ? 'edit' : 'create'} rejects ${key} before dictionary, duplicate check or write`, async () => {
const h = customerHarness(edit, { [key]: value })
const before = { ...h.form.value }
await h.save()
assert.deepEqual(h.calls, [])
assert.match(h.error.value, new RegExp(label))
assert.equal(h.notices.at(-1), h.error.value)
assert.deepEqual(h.form.value, before)
assert.equal(h.saving.value, false)
assert.deepEqual(h.navigations, [])
})
}
for (const patch of [
{ mobile: '', email: '' },
{ mobile: null, email: undefined },
{ mobile: '19900138000', email: 'a@example.com' }
]) {
test(`customer ${edit ? 'edit' : 'create'} accepts optional contact details ${JSON.stringify(patch)}`, async () => {
const h = customerHarness(edit, patch)
await h.save()
assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', edit ? 'update' : 'create'])
assert.equal(h.payloads[0].telephone, '办公总机 / 分机 123')
assert.equal(h.navigations.length, 1)
assert.equal(h.error.value, '')
})
}
}
test('customer invoice failure retries only the invoice, even if the locked customer data is now invalid', async () => {
let fail = true
const h = customerHarness(
false,
{},
{
invoice: async () => {
if (fail) throw new Error('开票保存失败')
}
}
)
h.invoice.value = { companyName: '测试单位', bankAccount: '自由账号' }
await h.save()
assert.equal(h.state.customerId, 'created-customer')
assert.equal(h.error.value, '开票保存失败')
assert.equal(h.notices.at(-1), h.error.value)
assert.deepEqual(h.navigations, [])
h.form.value.shortName = ''
h.form.value.email = 'bad'
fail = false
await h.save()
assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate', 'create', 'invoice', 'invoice'])
assert.equal(h.payloads.at(-1)?.customerId, 'created-customer')
assert.equal(h.payloads.at(-1)?.isDefault, '1')
assert.equal(h.navigations.length, 1)
})
test('customer duplicate detection preserves the draft and reports a visible reason', async () => {
const h = customerHarness(false, {}, { duplicate: async () => ({ isDuplicate: true }) })
const before = { ...h.form.value }
await h.save()
assert.deepEqual(h.calls, ['confirm', 'ensure', 'duplicate'])
assert.equal(h.payloads.length, 0)
assert.match(h.error.value, /客户名称已存在/)
assert.equal(h.notices.at(-1), h.error.value)
assert.deepEqual(h.form.value, before)
assert.equal(h.saving.value, false)
})
test('a failed duplicate check performs no customer write and permits retry', async () => {
let fail = true
const h = customerHarness(
false,
{},
{
duplicate: async () => {
if (fail) throw new Error('查重失败,请重试')
return { isDuplicate: false }
}
}
)
const before = { ...h.form.value }
await h.save()
assert.equal(h.payloads.length, 0)
assert.equal(h.error.value, '查重失败,请重试')
assert.equal(h.notices.at(-1), h.error.value)
assert.deepEqual(h.form.value, before)
assert.equal(h.saving.value, false)
fail = false
await h.save()
assert.equal(h.payloads.length, 1)
assert.equal(h.navigations.length, 1)
})
test('optional invoice company name is still validated before both initial save and invoice retry', async () => {
for (const customerId of ['', 'created-customer']) {
const h = customerHarness()
h.state.customerId = customerId
h.invoice.value = { companyName: ' ', bankAccount: '已有账号' }
await h.save()
assert.deepEqual(h.calls, [])
assert.match(h.error.value, /单位名称不能为空/)
assert.equal(h.notices.at(-1), h.error.value)
assert.equal(h.state.customerId, customerId)
assert.equal(h.invoice.value.bankAccount, '已有账号')
}
})
test('customer attachment upload blocks all requests with visible feedback', async () => {
const h = customerHarness()
h.uploads.set('businessLicenseImages', { hasUploading: () => true })
await h.save()
assert.deepEqual(h.calls, [])
assert.equal(h.error.value, '附件上传中,请稍候')
assert.equal(h.notices.at(-1), h.error.value)
})
function opportunityHarness(modeName: string, patch: model.Row = {}, write = async () => {}) {
const payloads: model.Row[] = []
const routes: string[] = []
const capture = (route: string) => async (data: model.Row) => {
routes.push(route)
payloads.push(data)
await write()
}
const bindings = {
...model,
...feedback(),
computed,
ref,
mode: ref(modeName),
busy: ref(false),
error: ref(''),
upload: ref(null),
customers: ref([]),
form: ref({
opportunityName: '测试商机',
customerId: 'customer-1',
stage: 'quote',
stagePercent: 60,
winRate: 65,
ownerUserId: 'owner-1',
changeReason: '推进原因',
followType: 'phone',
followTime: '2026-10-22T09:30',
followContent: '联系内容',
nextTime: '',
nextPlan: '下次计划',
attachments: 'fixture.pdf',
remark: '保留备注',
createTime: '只读字段',
status: 'following',
contactId: '不应覆盖关联联系人',
amount: 999,
...patch
}),
dictOptions: () => [],
OPPORTUNITY_TYPE_FALLBACK: {},
OPPORTUNITY_SOURCE_FALLBACK: {},
props: {
opportunity: { id: 'opportunity-1', stage: 'initial', contactId: 'contact-1', amount: 100 }
},
changeStage: capture('stage'),
updateOpportunity: capture('edit'),
addOpportunityFollow: capture('follow-create'),
updateOpportunityFollow: capture('follow-edit')
}
return {
...bindings,
...execute('OpportunityActions.vue', ['progress', 'fields', 'save'], bindings),
payloads,
routes
}
}
for (const mode of ['edit', 'stage']) {
for (const key of ['stagePercent', 'winRate']) {
for (const value of [-1, 101, NaN, Infinity, -Infinity, 'not-a-number']) {
test(`opportunity ${mode} rejects ${key}=${value} at the save entry`, async () => {
const h = opportunityHarness(mode, { [key]: value })
const before = { ...h.form.value }
await h.save()
assert.equal(h.payloads.length, 0)
assert.match(h.error.value, key === 'winRate' ? /赢单率/ : /阶段进度/)
assert.equal(h.notices.at(-1), h.error.value)
assert.deepEqual(h.form.value, before)
assert.equal(h.mode.value, mode)
assert.equal(h.busy.value, false)
})
}
}
for (const value of [0, 7, 100, '0', '100']) {
test(`opportunity ${mode} accepts ${value} without a multiple-of-five restriction`, async () => {
const h = opportunityHarness(mode, { stagePercent: value, winRate: value })
await h.save()
assert.deepEqual(h.routes, [mode])
assert.equal(h.payloads[0].stagePercent, Number(value))
assert.equal(h.payloads[0].winRate, Number(value))
assert.equal(h.mode.value, '')
})
}
}
for (const [mode, input, expected] of [
['edit', 12.345, 12.35],
['edit', 1.005, 1.01],
['edit', 4.015, 4.02],
['edit', 99.995, 100],
['edit', 12.3449, 12.34],
['stage', 7.5, 8],
['stage', 7.4, 7]
] as const) {
test(`opportunity ${mode} follows Web input precision for ${input}`, async () => {
const h = opportunityHarness(mode, { stagePercent: input, winRate: input })
await h.save()
assert.equal(h.payloads[0].winRate, expected)
assert.equal(h.payloads[0].stagePercent, mode === 'edit' ? input : expected)
assert.equal(h.form.value.winRate, input)
})
}
test('opportunity edit retains hidden writable associations and excludes readonly fields', async () => {
const h = opportunityHarness('edit')
await h.save()
assert.equal(h.payloads[0].contactId, 'contact-1')
assert.equal(h.payloads[0].amount, 100)
assert.equal(h.payloads[0].id, 'opportunity-1')
assert.equal(h.payloads[0].remark, '保留备注')
assert.equal(Object.hasOwn(h.payloads[0], 'status'), false)
assert.equal(Object.hasOwn(h.payloads[0], 'createTime'), false)
})
test('opportunity stage rejects unchanged stage and preserves the draft', async () => {
const h = opportunityHarness('stage', { stage: 'initial' })
await h.save()
assert.equal(h.payloads.length, 0)
assert.match(h.error.value, /相同/)
assert.equal(h.notices.at(-1), h.error.value)
assert.equal(h.mode.value, 'stage')
})
test('opportunity stage keeps its narrow payload', async () => {
const h = opportunityHarness('stage')
await h.save()
assert.deepEqual(h.payloads[0], {
id: 'opportunity-1',
stage: 'quote',
stagePercent: 60,
winRate: 65,
changeReason: '推进原因',
remark: '保留备注'
})
})
for (const mode of ['edit', 'stage', 'follow']) {
test(`opportunity ${mode} retains all required-field guards with field-specific feedback`, async () => {
const original = opportunityHarness(mode)
for (const field of original.fields.value.filter((f: model.Field) => f.required)) {
const h = opportunityHarness(mode, { [field.key]: ' ' })
await h.save()
assert.equal(h.payloads.length, 0)
assert.ok(h.error.value.includes(field.label), h.error.value)
assert.equal(h.notices.at(-1), h.error.value)
}
})
}
test('opportunity name minimum length remains enforced', async () => {
const h = opportunityHarness('edit', { opportunityName: '单' })
await h.save()
assert.equal(h.payloads.length, 0)
assert.match(h.error.value, /至少两个/)
})
for (const edit of [false, true]) {
test(`opportunity follow ${edit ? 'edit' : 'create'} retains its payload and optional next date`, async () => {
const h = opportunityHarness('follow', edit ? { id: 'follow-1' } : {})
await h.save()
assert.deepEqual(h.routes, [edit ? 'follow-edit' : 'follow-create'])
assert.equal(h.payloads[0].opportunityId, 'opportunity-1')
assert.equal(h.payloads[0].followTime, '2026-10-22 09:30:00')
assert.equal(h.payloads[0].nextTime, '')
assert.equal(h.payloads[0].nextContent, '下次计划')
assert.equal(h.payloads[0].attachments, 'fixture.pdf')
})
}
const lifecycleFactories = {
contact: (write: () => Promise<void>) => {
const h = contactHarness(false, {}, write)
return { ...h, pending: h.acting, error: h.saveError, opened: () => h.editing.value }
},
customer: (write: () => Promise<void>) => {
const h = customerHarness(false, {}, { write })
return { ...h, pending: h.saving, opened: () => h.navigations.length === 0 }
},
opportunity: (write: () => Promise<void>) => {
const h = opportunityHarness('edit', {}, write)
return { ...h, pending: h.busy, opened: () => h.mode.value === 'edit' }
}
}
for (const [name, factory] of Object.entries(lifecycleFactories)) {
test(`${name} API failure retains draft and supports retry`, async () => {
let fail = true
const h = factory(async () => {
if (fail) throw new Error('保存失败测试')
})
const before = { ...h.form.value }
await h.save()
assert.equal(h.error.value, '保存失败测试')
assert.equal(h.notices.at(-1), h.error.value)
assert.equal(h.opened(), true)
assert.equal(h.pending.value, false)
assert.deepEqual(h.form.value, before)
fail = false
await h.save()
assert.equal(h.payloads.length, 2)
assert.equal(h.error.value, '')
})
test(`${name} pending save blocks a second submission`, async () => {
let resolve!: () => void
const h = factory(
() =>
new Promise<void>((done) => {
resolve = done
})
)
const pending = h.save()
await h.save()
while (!resolve) await Promise.resolve()
assert.equal(h.pending.value, true)
assert.equal(h.payloads.length, 1)
resolve()
await pending
assert.equal(h.pending.value, false)
})
}
test('number field bounds reach Vant and all affected SFCs compile', () => {
const source = readFileSync(new URL('CustomerFields.vue', base), 'utf8')
assert.match(source, /:min="field.minValue"/)
assert.match(source, /:max="field.maxValue"/)
for (const file of [
'CustomerFields.vue',
'ContactPanel.vue',
'form.vue',
'FollowPanel.vue',
'OpportunityActions.vue'
]) {
const { descriptor, errors } = parse(readFileSync(new URL(file, base), 'utf8'))
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: file })
const template = compileTemplate({
id: file,
filename: file,
source: descriptor.template!.content,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
}
})