crm-existing-issues.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { runInNewContext } from 'node:vm'
  5. import ts from 'typescript'
  6. import { parse } from '@vue/compiler-sfc'
  7. import { createSSRApp, h } from 'vue'
  8. import { renderToString } from 'vue/server-renderer'
  9. import { parseTime } from '../src/utils/format'
  10. // 执行组件中的真实处理函数,只替换网络与弹窗边界,避免写入业务库。
  11. function source(path: string) {
  12. const file = readFileSync(new URL('../src/views/crm/' + path, import.meta.url), 'utf8')
  13. return ts.createSourceFile(
  14. path,
  15. parse(file).descriptor.scriptSetup!.content,
  16. ts.ScriptTarget.Latest,
  17. true
  18. )
  19. }
  20. function evaluate(code: string, context: Record<string, unknown>) {
  21. return runInNewContext(
  22. ts.transpileModule(code, {
  23. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }
  24. }).outputText,
  25. context
  26. )
  27. }
  28. function handler(path: string, name: string, context: Record<string, unknown>) {
  29. const file = source(path)
  30. const statement = file.statements.find(
  31. (s) =>
  32. (ts.isFunctionDeclaration(s) && s.name?.text === name) ||
  33. (ts.isVariableStatement(s) &&
  34. s.declarationList.declarations.some((d) => d.name.getText(file) === name))
  35. )
  36. assert.ok(statement, `组件应保留 ${name} 处理入口`)
  37. return evaluate(statement.getText(file) + '\n' + name, context)
  38. }
  39. for (const [path, name, api] of [
  40. ['customer/FollowFormDialog.vue', 'loadContactList', 'fetchContactByCustomerId'],
  41. ['customer/components/ContactList.vue', 'getList', 'fetchContactsByCustomerId']
  42. ]) {
  43. test(`${path}:解析实际已解包联系人数组并兼容旧包装`, async () => {
  44. const rows = [{ id: '90071992547409931', contactName: '联系人甲' }]
  45. for (const response of [rows, { data: rows }, [], null]) {
  46. const contactList = { value: [{ id: 'old' }] }
  47. const load = handler(path, name, {
  48. [api]: async () => response,
  49. contactList,
  50. props: { customerId: 'customer-1' },
  51. loading: { value: false },
  52. console
  53. })
  54. await load('customer-1')
  55. assert.equal(
  56. JSON.stringify(contactList.value),
  57. JSON.stringify(
  58. response === rows || (!Array.isArray(response) && response?.data) ? rows : []
  59. )
  60. )
  61. }
  62. })
  63. test(`${path}:请求失败清空旧联系人`, async () => {
  64. const contactList = { value: [{ id: 'old' }] }
  65. const loading = { value: false }
  66. const load = handler(path, name, {
  67. [api]: async () => {
  68. throw new Error('offline')
  69. },
  70. contactList,
  71. props: { customerId: 'customer-1' },
  72. loading,
  73. console: { error() {} }
  74. })
  75. await load('customer-1')
  76. assert.equal(contactList.value.length, 0)
  77. assert.equal(loading.value, false)
  78. })
  79. }
  80. test('操作日志各字段只能作为文本渲染,不能注入标签或事件', async () => {
  81. const payload = '<img src=x onerror="alert(1)"></pre><script>alert(2)</script>&"\n第二行'
  82. let message: any
  83. let options: any
  84. const show = handler('customer/CustomerDetailDialog.vue', 'handleViewLogDetail', {
  85. h,
  86. ElMessageBox: {
  87. alert(content: unknown, _title: string, config: unknown) {
  88. message = content
  89. options = config
  90. }
  91. }
  92. })
  93. show(
  94. Object.fromEntries(
  95. ['title', 'operName', 'operTime', 'operUrl', 'operParam'].map((k) => [k, payload])
  96. )
  97. )
  98. const html =
  99. typeof message === 'string' && options.dangerouslyUseHTMLString
  100. ? message
  101. : await renderToString(createSSRApp({ render: () => message }))
  102. assert.doesNotMatch(html, /<img|<script/i)
  103. assert.equal((html.match(/&lt;img/g) || []).length, 5)
  104. assert.ok(html.includes('第二行'))
  105. assert.notEqual(options.dangerouslyUseHTMLString, true)
  106. })
  107. test('新增商机跟进默认时间使用本地时间,跨日不偏移;编辑不覆盖已有值', async () => {
  108. const previousTZ = process.env.TZ
  109. process.env.TZ = 'Asia/Shanghai'
  110. try {
  111. const file = source('opportunity/components/FollowDialog.vue')
  112. const watcher = file.statements.find(
  113. (s) =>
  114. ts.isExpressionStatement(s) &&
  115. ts.isCallExpression(s.expression) &&
  116. s.expression.expression.getText(file) === 'watch'
  117. )!
  118. for (const instant of ['2026-10-17T20:01:01+08:00', '2026-10-17T00:01:01+08:00']) {
  119. let callback: (open: boolean) => Promise<void>
  120. const form = { value: {} as Record<string, unknown> }
  121. const props: any = { opportunityId: '90071992547409931' }
  122. evaluate(watcher.getText(file), {
  123. watch: (_source: unknown, fn: typeof callback) => {
  124. callback = fn
  125. },
  126. props,
  127. form,
  128. visible: { value: false },
  129. attachmentUrls: { value: '' },
  130. resetForm: () => {
  131. form.value = {}
  132. },
  133. Date: class extends Date {
  134. constructor() {
  135. super(instant)
  136. }
  137. },
  138. parseTime,
  139. getOpportunityFollow: async () => ({
  140. data: {
  141. id: 'follow-1',
  142. followTime: '2026-10-16 09:30:12',
  143. nextTime: '2026-10-17 20:01:01'
  144. }
  145. })
  146. })
  147. await callback!(true)
  148. assert.equal(form.value.followTime, instant.slice(0, 19).replace('T', ' '))
  149. props.followData = { id: 'follow-1' }
  150. await callback!(true)
  151. assert.equal(form.value.followTime, '2026-10-16 09:30:12')
  152. assert.equal(form.value.nextTime, '2026-10-17 20:01:01')
  153. }
  154. } finally {
  155. if (previousTZ == null) delete process.env.TZ
  156. else process.env.TZ = previousTZ
  157. }
  158. })