mobile-customer-panel-identity.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync, existsSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import * as vue from 'vue'
  7. function deferred() {
  8. let resolve!: (value?: any) => void
  9. let reject!: (error: any) => void
  10. const promise = new Promise<any>((a, b) => {
  11. resolve = a
  12. reject = b
  13. })
  14. return { promise, resolve, reject }
  15. }
  16. const flush = async () => {
  17. for (let i = 0; i < 20; i++) await Promise.resolve()
  18. }
  19. // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred.
  20. const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url)
  21. function evaluate(page: string, props: any, overrides: Record<string, any>, events: any[]) {
  22. const source = parse(
  23. readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8')
  24. ).descriptor.scriptSetup!.content
  25. const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true)
  26. const vueEntries = Object.entries(vue).filter(
  27. ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default'
  28. )
  29. const bindings: Record<string, any> = Object.fromEntries(vueEntries)
  30. delete bindings.default
  31. for (const statement of ast.statements) {
  32. if (!ts.isImportDeclaration(statement)) continue
  33. const clause = statement.importClause
  34. if (clause?.name) bindings[clause.name.text] = () => undefined
  35. if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
  36. for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
  37. }
  38. if (existsSync(helperPath)) {
  39. const helper = ts.transpile(
  40. readFileSync(helperPath, 'utf8').replace('export function', 'function'),
  41. { target: ts.ScriptTarget.ESNext }
  42. )
  43. bindings.useCustomerPanelIdentity = new Function(
  44. ...vueEntries.map(([k]) => k),
  45. `${helper};return useCustomerPanelIdentity`
  46. )(...vueEntries.map(([, v]) => v))
  47. }
  48. Object.assign(bindings, {
  49. defineProps: () => props,
  50. defineEmits:
  51. () =>
  52. (...args: any[]) =>
  53. events.push(args),
  54. defineExpose() {},
  55. useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }),
  56. unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []),
  57. unwrapData: (v: any) => v?.data || v,
  58. canDeleteByCreator: () => true,
  59. contactFields: [{ key: 'contactName', required: true }],
  60. invoiceFields: [{ key: 'companyName' }],
  61. requiredFieldsError: () => '',
  62. contactDetailsError: () => '',
  63. percentageError: () => '',
  64. apiTime: (v: any) => v,
  65. opportunityProgress: () => ({}),
  66. showCrmConfirmDialog: async () => {},
  67. fetchContactByCustomerId: async () => [],
  68. fetchFollowByCustomerId: async () => [],
  69. getInvoiceInfoByCustomerId: async () => [],
  70. fetchContactDetail: async (v: any) => v,
  71. fetchUserOptions: async () => [],
  72. fetchCustomerOptions: async () => [],
  73. listOpportunity: async () => [],
  74. listContract: async () => [],
  75. listOperLog: async () => [],
  76. getOpportunity: async (id: string) => ({ id }),
  77. listFollowByOpportunity: async () => [],
  78. getStageLogList: async () => [],
  79. ...overrides
  80. })
  81. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  82. const names = statements.flatMap((s) =>
  83. ts.isFunctionDeclaration(s)
  84. ? [s.name!.text]
  85. : ts.isVariableStatement(s)
  86. ? s.declarationList.declarations.map((d) => d.name.getText(ast))
  87. : []
  88. )
  89. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  90. target: ts.ScriptTarget.ESNext
  91. })
  92. return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  93. ...Object.values(bindings)
  94. )
  95. }
  96. function mount(page: string, overrides: Record<string, any> = {}) {
  97. const routeState = vue.reactive({ params: { id: 'A' }, query: { tab: 'basic' }, path: '/A' })
  98. const route = {
  99. get id() {
  100. return routeState.params.id
  101. },
  102. set id(id: string) {
  103. routeState.params.id = id
  104. },
  105. get tab() {
  106. return routeState.query.tab
  107. },
  108. set tab(tab: string) {
  109. routeState.query.tab = tab
  110. }
  111. }
  112. const events: any[] = []
  113. let api: any
  114. let actions: any
  115. const renderer = vue.createRenderer<any, any>({
  116. patchProp() {},
  117. insert() {},
  118. remove() {},
  119. createElement: () => ({}),
  120. createText: () => ({}),
  121. createComment: () => ({}),
  122. setText() {},
  123. setElementText() {},
  124. parentNode: () => null,
  125. nextSibling: () => null
  126. })
  127. const NestedActions = vue.defineComponent({
  128. props: ['opportunity', 'getIdentity'],
  129. setup(props) {
  130. actions = evaluate('OpportunityActions', props, overrides, events)
  131. return () => vue.h('div')
  132. }
  133. })
  134. const Child = vue.defineComponent({
  135. props: ['customerId', 'opportunity', 'getIdentity', 'kind'],
  136. setup(props) {
  137. api = evaluate(
  138. page === 'RelatedPanelActions' ? 'RelatedPanel' : page,
  139. props,
  140. overrides,
  141. events
  142. )
  143. return () =>
  144. page === 'RelatedPanelActions' && api.detail.value && api.detailOpen.value
  145. ? vue.h(NestedActions, { opportunity: api.detail.value, getIdentity: api.identity.key })
  146. : vue.h('div')
  147. }
  148. })
  149. const Parent = vue.defineComponent({
  150. setup() {
  151. const parent = evaluate(
  152. 'detail',
  153. {},
  154. { useRoute: () => routeState, useRouter: () => ({ replace() {}, push() {} }) },
  155. []
  156. )
  157. const getIdentity = parent.getPanelIdentity || (() => route.id)
  158. return () =>
  159. vue.h(Child, {
  160. customerId: route.id,
  161. opportunity: { id: route.id, status: 'following' },
  162. kind: 'opportunity',
  163. getIdentity
  164. })
  165. }
  166. })
  167. const app = renderer.createApp(Parent)
  168. app.mount({})
  169. return {
  170. get api() {
  171. return api
  172. },
  173. get actions() {
  174. return actions
  175. },
  176. route,
  177. events,
  178. unmount: () => app.unmount()
  179. }
  180. }
  181. const listApis: Record<string, string> = {
  182. ContactPanel: 'fetchContactByCustomerId',
  183. FollowPanel: 'fetchFollowByCustomerId',
  184. InvoicePanel: 'getInvoiceInfoByCustomerId',
  185. RelatedPanel: 'listOpportunity'
  186. }
  187. for (const [page, read] of Object.entries(listApis)) {
  188. test(`${page}: late initial load cannot overwrite B or ABA`, async () => {
  189. const old = deferred()
  190. let calls = 0
  191. const h = mount(page, {
  192. [read]: () => (++calls === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }]))
  193. })
  194. h.route.id = 'B'
  195. await flush()
  196. h.route.id = 'A'
  197. await flush()
  198. old.resolve([{ id: 'old' }])
  199. await flush()
  200. assert.deepEqual(h.api.rows.value, [{ id: 'fresh' }])
  201. h.unmount()
  202. })
  203. }
  204. const deletes: Record<string, string> = {
  205. ContactPanel: 'fetchDeleteContact',
  206. FollowPanel: 'fetchDeleteCustomerFollow',
  207. InvoicePanel: 'delInvoiceInfo',
  208. OpportunityActions: 'delOpportunity'
  209. }
  210. for (const [page, write] of Object.entries(deletes)) {
  211. for (const outcome of ['switch', 'ABA', 'unmount'])
  212. test(`${page}: confirmation resolves before same-tick ${outcome}`, async () => {
  213. const confirm = deferred(),
  214. writes: any[] = []
  215. const h = mount(page, {
  216. showCrmConfirmDialog: () => confirm.promise,
  217. [write]: async (v: any) => writes.push(v)
  218. })
  219. await flush()
  220. const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' })
  221. confirm.resolve()
  222. if (outcome === 'unmount') h.unmount()
  223. else {
  224. h.route.id = 'B'
  225. if (outcome === 'ABA') h.route.id = 'A'
  226. }
  227. await pending
  228. assert.deepEqual(writes, [])
  229. assert.deepEqual(h.events, [])
  230. if (outcome !== 'unmount') h.unmount()
  231. })
  232. test(`${page}: same-ID tab query retains legitimate confirmation`, async () => {
  233. const confirm = deferred(),
  234. writes: any[] = []
  235. const h = mount(page, {
  236. showCrmConfirmDialog: () => confirm.promise,
  237. [write]: async (v: any) => writes.push(v)
  238. })
  239. await flush()
  240. const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' })
  241. h.route.tab = 'contact'
  242. confirm.resolve()
  243. await pending
  244. assert.equal(writes.length, 1)
  245. h.unmount()
  246. })
  247. }
  248. test('ContactPanel: reversed detail opens retain latest row', async () => {
  249. const first = deferred(),
  250. second = deferred()
  251. const h = mount('ContactPanel', {
  252. fetchContactDetail: ({ id }: any) => (id === 'one' ? first.promise : second.promise)
  253. })
  254. const a = h.api.open({ id: 'one' }),
  255. b = h.api.open({ id: 'two' })
  256. second.resolve({ id: 'two' })
  257. await b
  258. first.resolve({ id: 'one' })
  259. await a
  260. assert.equal(h.api.form.value.id, 'two')
  261. h.unmount()
  262. })
  263. test('OpportunityActions: reversed customer searches retain latest query', async () => {
  264. const first = deferred(),
  265. second = deferred()
  266. const h = mount('OpportunityActions', {
  267. fetchCustomerOptions: ({ keyword }: any) => (keyword === 'one' ? first.promise : second.promise)
  268. })
  269. await h.api.open('edit')
  270. h.api.customerKeyword.value = 'one'
  271. const a = h.api.searchCustomers()
  272. h.api.customerKeyword.value = 'two'
  273. const b = h.api.searchCustomers()
  274. second.resolve([{ id: 'two', customerName: 'two' }])
  275. await b
  276. first.resolve([{ id: 'one', customerName: 'one' }])
  277. await a
  278. assert.equal(h.api.customers.value[0].value, 'two')
  279. h.unmount()
  280. })
  281. const saves: Record<string, string> = {
  282. ContactPanel: 'fetchCreateContact',
  283. FollowPanel: 'fetchCreateCustomerFollow',
  284. InvoicePanel: 'addInvoiceInfo',
  285. OpportunityActions: 'addOpportunityFollow'
  286. }
  287. for (const [page, write] of Object.entries(saves)) {
  288. test(`${page}: old save completion does not close, emit or clear new busy`, async () => {
  289. const old = deferred(),
  290. fresh = deferred()
  291. let count = 0
  292. const h = mount(page, { [write]: () => (++count === 1 ? old.promise : fresh.promise) })
  293. await flush()
  294. await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open())
  295. Object.assign(h.api.form.value, {
  296. contactName: 'A',
  297. companyName: 'A',
  298. followContent: 'A',
  299. followType: 'phone'
  300. })
  301. const a = h.api.save()
  302. h.route.id = 'B'
  303. await flush()
  304. await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open())
  305. Object.assign(h.api.form.value, {
  306. contactName: 'B',
  307. companyName: 'B',
  308. followContent: 'B',
  309. followType: 'phone'
  310. })
  311. const b = h.api.save()
  312. old.resolve()
  313. await a
  314. assert.equal((h.api.acting || h.api.busy).value, true)
  315. assert.equal(
  316. (h.api.editing || h.api.mode).value,
  317. page === 'OpportunityActions' ? 'follow' : true
  318. )
  319. assert.deepEqual(h.events, [])
  320. fresh.resolve()
  321. await b
  322. assert.equal((h.api.acting || h.api.busy).value, false)
  323. h.unmount()
  324. })
  325. }
  326. test('FollowPanel: close/reopen ignores old contacts error', async () => {
  327. const first = deferred(),
  328. second = deferred()
  329. let calls = 0
  330. const h = mount('FollowPanel', {
  331. fetchContactByCustomerId: () => (++calls === 1 ? first.promise : second.promise)
  332. })
  333. const a = h.api.open()
  334. h.api.closeEditing()
  335. const b = h.api.open()
  336. second.resolve([{ id: 'new' }])
  337. await b
  338. first.reject(new Error('old error'))
  339. await a
  340. assert.equal(h.api.saveError.value, '')
  341. assert.deepEqual(h.api.contacts.value, [{ id: 'new' }])
  342. h.unmount()
  343. })
  344. test('RelatedPanel: reversed opportunity opens keep fresh detail and actions', async () => {
  345. const first = deferred(),
  346. second = deferred()
  347. const h = mount('RelatedPanel', {
  348. getOpportunity: (id: string) => (id === 'one' ? first.promise : second.promise)
  349. })
  350. const a = h.api.open({ id: 'one' }),
  351. b = h.api.open({ id: 'two' })
  352. second.resolve({ id: 'two' })
  353. await b
  354. first.resolve({ id: 'one' })
  355. await a
  356. assert.equal(h.api.detail.value.id, 'two')
  357. h.unmount()
  358. })
  359. for (const transition of ['openB', 'close', 'closeReopenA', 'routeABA']) {
  360. test(`nested RelatedPanel/OpportunityActions: confirmation cannot survive ${transition}`, async () => {
  361. const confirm = deferred(),
  362. writes: any[] = []
  363. const h = mount('RelatedPanelActions', {
  364. showCrmConfirmDialog: () => confirm.promise,
  365. delOpportunity: async (id: string) => writes.push(id)
  366. })
  367. await flush()
  368. await h.api.open({ id: 'opportunity-A' })
  369. await flush()
  370. const pending = h.actions.remove()
  371. confirm.resolve()
  372. if (transition === 'openB') void h.api.open({ id: 'opportunity-B' })
  373. if (transition === 'close') h.api.closeDetail()
  374. if (transition === 'closeReopenA') {
  375. h.api.closeDetail()
  376. void h.api.open({ id: 'opportunity-A' })
  377. }
  378. if (transition === 'routeABA') {
  379. h.route.id = 'B'
  380. h.route.id = 'A'
  381. }
  382. await pending
  383. assert.deepEqual(writes, [])
  384. assert.deepEqual(h.events, [])
  385. h.unmount()
  386. })
  387. }
  388. for (const [page, read] of Object.entries(listApis)) {
  389. test(`${page}: old list error and finally cannot overwrite B loading`, async () => {
  390. const first = deferred(),
  391. second = deferred()
  392. let calls = 0
  393. const h = mount(page, { [read]: () => (++calls === 1 ? first.promise : second.promise) })
  394. h.route.id = 'B'
  395. await flush()
  396. first.reject(new Error('old error'))
  397. await flush()
  398. assert.equal(h.api.error.value, '')
  399. assert.equal(h.api.loading.value, true)
  400. second.resolve([{ id: 'B' }])
  401. await flush()
  402. assert.deepEqual(h.api.rows.value, [{ id: 'B' }])
  403. h.unmount()
  404. })
  405. }
  406. test('ContactPanel: stale primary confirmation cannot set a row on the next customer', async () => {
  407. const confirm = deferred(),
  408. writes: any[] = []
  409. const h = mount('ContactPanel', {
  410. showCrmConfirmDialog: () => confirm.promise,
  411. fetchSetPrimaryContact: async (v: any) => writes.push(v)
  412. })
  413. await flush()
  414. const pending = h.api.primary({ id: 'contact-A' })
  415. confirm.resolve()
  416. h.route.id = 'B'
  417. await pending
  418. assert.deepEqual(writes, [])
  419. h.unmount()
  420. })
  421. test('InvoicePanel: default write failure cannot replace the next customer error or busy', async () => {
  422. const first = deferred(),
  423. second = deferred()
  424. let calls = 0
  425. const h = mount('InvoicePanel', {
  426. request: { post: () => (++calls === 1 ? first.promise : second.promise) }
  427. })
  428. await flush()
  429. const a = h.api.setDefault({ id: 'invoice-A' })
  430. h.route.id = 'B'
  431. await flush()
  432. const b = h.api.setDefault({ id: 'invoice-B' })
  433. first.reject(new Error('old error'))
  434. await a
  435. assert.equal(h.api.error.value, '')
  436. assert.equal(h.api.acting.value, true)
  437. second.resolve()
  438. await b
  439. assert.equal(h.api.acting.value, false)
  440. h.unmount()
  441. })
  442. test('OpportunityActions: old owner options cannot replace a reopened edit session', async () => {
  443. const first = deferred(),
  444. second = deferred()
  445. let calls = 0
  446. const h = mount('OpportunityActions', {
  447. fetchUserOptions: () => (++calls === 1 ? first.promise : second.promise)
  448. })
  449. const a = h.api.open('edit')
  450. const b = h.api.open('edit')
  451. second.resolve([{ value: 'B', label: 'B' }])
  452. await b
  453. first.resolve([{ value: 'A', label: 'A' }])
  454. await a
  455. assert.deepEqual(h.api.users.value, [{ value: 'B', label: 'B' }])
  456. h.unmount()
  457. })
  458. test('OpportunityActions: delete uses the captured follow ID', async () => {
  459. const confirm = deferred(),
  460. writes: any[] = []
  461. const h = mount('OpportunityActions', {
  462. showCrmConfirmDialog: () => confirm.promise,
  463. delOpportunityFollow: async (v: any) => writes.push(v)
  464. })
  465. const row = { id: 'follow-A' }
  466. const pending = h.api.remove(row)
  467. row.id = 'follow-B'
  468. confirm.resolve()
  469. await pending
  470. assert.deepEqual(writes, ['follow-A'])
  471. assert.deepEqual(h.events, [['changed']])
  472. h.unmount()
  473. })